Compare commits

..

175 commits

Author SHA1 Message Date
Carter
696fbb3593 wrapping scrollIntoView in {} to prevent it from returning void 2026-07-21 11:59:48 -06:00
Carter
ddd0b5062b settings forms to prevent it from revert user changed 2026-07-17 14:58:31 -06:00
Carter
80750bd3e9 hotfix: mode node id can be anything > 0 2026-07-16 14:22:33 -06:00
Carter
65f8fd06d3 hotfix: mode node id inputs 2026-07-16 12:47:56 -06:00
Carter
199511e829 Merge branch 'master' of gitlab.com:brandx/bxt-app 2026-07-13 13:02:28 -06:00
Carter
b272d264b6 updated master proto 2026-07-13 13:01:59 -06:00
Carter
cb168aef9b Merge branch 'tag_fix3' into staging_environment 2026-07-10 10:12:01 -06:00
Carter
d124db62e0 got rid of scanned logs 2026-07-10 10:11:46 -06:00
Carter
de662907c6 Merge branch 'tag_fix3' into staging_environment 2026-07-09 11:58:39 -06:00
Carter
487afc6464 removed component type logs 2026-07-09 11:58:32 -06:00
csawatzky
fc347938d2 hotfix for analog pressure, is shoudl support expansion and not cable ID 2026-07-09 11:32:32 -06:00
Carter
72c920404c Merge branch 'tag_fix3' into staging_environment 2026-07-08 16:10:24 -06:00
Carter
3fc663a4b6 removed interaction logs 2026-07-08 15:56:03 -06:00
csawatzky
2c17fb655c hotfix to add as to the historyBetween function in the component API so that it works when viewing as a team without direct user permission to it 2026-07-08 11:01:45 -06:00
Carter
71fb67bba4 pending changes indication for device and component changes 2026-07-07 11:28:34 -06:00
Carter
10f0aa46cc merged staging 2026-06-30 10:51:17 -06:00
Carter
8706446108 proto update and audit 2026-06-30 10:50:29 -06:00
Carter
9534eed94c Merge branch 'new_header' into staging_environment 2026-06-30 09:47:51 -06:00
Carter
3eb66e3622 changed header to match new theme 2026-06-30 09:43:38 -06:00
csawatzky
5dc3a3e975 Merge branch 'bin_player_inventory_changes' into staging_environment 2026-06-29 14:17:48 -06:00
csawatzky
7666fbf9b6 update to the bin player to set the bin bushels in the status using the measurements from the bin measurements 2026-06-26 15:25:18 -06:00
Carter
64fcc934ce Merge branch 'email_control' into staging_environment 2026-06-25 14:04:04 -06:00
Carter
cca981308d Merge branch 'pending_changes' into staging_environment 2026-06-25 12:48:28 -06:00
Carter
ffd5e0a769 preventing form updates when settings dialog is open 2026-06-25 12:33:14 -06:00
Carter
e8cc509915 less restrictive on receiving websockets 2026-06-25 12:15:49 -06:00
Carter
b9a13c5781 fixed pending change race condition 2026-06-24 14:40:04 -06:00
Carter
879a5ae1f0 fixed pending state logic 2026-06-24 14:16:54 -06:00
Carter
895ed8e50b opening interaction websocket to detect accepted pending changes 2026-06-24 13:13:14 -06:00
csawatzky
4ea0d53c94 adding analog pressure to have the cable id show up for analog cables 2026-06-24 12:38:29 -06:00
Carter
6b07859d00 Merge branch 'firmware_mismatch' into pending_changes 2026-06-24 12:13:50 -06:00
csawatzky
f73eb0fcbd proto update 2026-06-23 16:44:42 -06:00
csawatzky
728d07a07e proto update 2026-06-23 16:42:30 -06:00
csawatzky
e19e1f03eb Merge branch 'analog_pressure_component' into staging_environment 2026-06-23 16:41:36 -06:00
csawatzky
270df1a118 fixed unit from V to mV for analog pressure 2026-06-23 16:40:09 -06:00
csawatzky
939a53d072 had claude look at the bin player to determine how to take into account the component history for the top nodes 2026-06-23 16:25:31 -06:00
Carter
0984ae0f37 got rid of warning signifier on firmware print 2026-06-17 16:19:35 -06:00
Carter
d9846ea02b firmware renders with warning styling if it wasn't found in the server 2026-06-17 15:41:26 -06:00
Carter
b378764d62 added some info about email control to the control tab for the component settings 2026-06-17 14:11:43 -06:00
csawatzky
a4ed52db1e also displaying the inventory control type next to "Bin Fill" in the inventory summary and added a fallback to display the inventory in the bin using the basic grain fill when there are no nodes to be able to render the heatmaps, also adjusted the brightness and colour of the basic grain fill 2026-06-16 12:40:07 -06:00
Carter
c4999a0ba5 adding control emails to control component settings form 2026-06-16 12:32:43 -06:00
csawatzky
1051534bb5 proto update to stagings proto 2026-06-12 14:58:30 -06:00
csawatzky
bbc9c4ee7f changing some naming for the component 2026-06-12 14:43:37 -06:00
csawatzky
370134a401 Merge branch '3d_bin' into staging_environment 2026-06-12 14:17:54 -06:00
csawatzky
964af32ec0 commented out unused parts of the new bin page, dont know if they will be used in the future so that is why they are commented and not removed 2026-06-12 14:14:12 -06:00
csawatzky
cd5fbb29b7 changing the visualizer to use the bins status controllers rather than controllers being passed in 2026-06-12 14:05:32 -06:00
csawatzky
a6b31c30c9 implemented touch controls for the camera 2026-06-12 11:43:58 -06:00
csawatzky
e8c31b23ee adjusted the default zoom level when loading the bin page 2026-06-12 11:33:42 -06:00
csawatzky
ea13f97c57 more visual tweaks and design, fixed a coule bugs and removed some console logs 2026-06-11 16:48:00 -06:00
Carter
7b1642bb1a changing user settings defined whitelabel when mismatched, giving team whitelabel if it's empty 2026-06-11 13:37:07 -06:00
csawatzky
f04213af1e full width on desktop scollable on mobile 2026-06-10 16:42:00 -06:00
csawatzky
124856baac made some changes to the checkbox for the labels, and made sure that the tabs are scrollable in mobile view 2026-06-10 16:40:50 -06:00
csawatzky
2cffc20daa finished some tweaks for the new bin page 2026-06-10 16:06:07 -06:00
csawatzky
87382dd5d8 cables now start at the top of the sidewall and dont go into the roof, added skeletons to the bin page for when it is loading, also fixed an issue with the camera being very zoomed in sometimes when the page loads 2026-06-09 15:47:44 -06:00
Carter
7dd7504415 hiding help when it's unavailable 2026-06-09 10:20:46 -06:00
Carter
c35b6b1f15 clicking help opens chat assistant 2026-06-09 10:06:22 -06:00
csawatzky
f6de509561 finished tweaking the bin players visual aspect and controls, made some smaller visual adjustments and added a checkbox for showing labels 2026-06-08 16:58:47 -06:00
Carter
9d6af5dd16 added a help button at the bottom of the scrollbar 2026-06-08 16:19:46 -06:00
csawatzky
d30ed99ca7 play button works, just need to build out the controls 2026-06-05 16:06:32 -06:00
csawatzky
1dbf3a96c6 started work on the 'player' for bins 2026-06-04 16:47:07 -06:00
csawatzky
ac26455110 made a new function for the mobile inventory section, since it may differ drastically fomr the desktop rather than just re-size some stuff 2026-06-03 16:23:01 -06:00
csawatzky
160d2a9d31 set up some defaults for bins that are not using advanced dimensions so that they can still have a visual of some sort 2026-06-03 15:59:14 -06:00
Carter
7f1b907fa0 can sign up with username instead of email on local network server 2026-06-03 14:57:48 -06:00
Carter
9324655a9e just an error catch 2026-06-03 14:18:51 -06:00
csawatzky
1d19a7e993 fixed the hopper fill only filling it completely and not doing the partial fill 2026-06-03 12:49:03 -06:00
csawatzky
13c15ec195 finished hooking up the inventory adjustment slider when using manual control on the bin page 2026-06-03 12:36:08 -06:00
csawatzky
b8a49197cb created a change grain dialog component and made the grain display a 'button' to be able to change the grain without going into the settings 2026-06-02 15:20:04 -06:00
csawatzky
dc52ef77a7 finished the expanded cables dialog, worked on the scrolling for bin cables in the pages table view, did some mobile view edits 2026-06-01 15:12:13 -06:00
Carter
f342940283 replaced provision checks with admin when doing admin actions that aren't provision 2026-06-01 14:42:58 -06:00
csawatzky
c3ee4a518c added and all nodes option for the cards and graphs 2026-05-29 12:03:56 -06:00
csawatzky
99f37f7bda built out the graphs for the bin sensor display 2026-05-28 14:56:59 -06:00
csawatzky
b00f10b831 finished the bin sensor view to display connected components for a bin 2026-05-28 11:18:40 -06:00
Carter
184704aee3 Merge branch 'staging_environment' 2026-05-26 11:39:23 -06:00
Carter
a503148238 Merge branch 'local_server' into staging_environment 2026-05-26 08:15:03 -06:00
Carter
a13c9d5946 using callback for tags 2026-05-25 12:25:22 -06:00
Carter
07b0e6c41e detach tag is now properly using context 2026-05-22 15:57:30 -06:00
csawatzky
67b251d865 added graphs to the bin details section on the new page and started working on the sensors 2026-05-22 13:12:54 -06:00
Carter
312cc6e2dc build info on bottom left of screen area 2026-05-22 11:09:18 -06:00
Carter
d864a9c1de Merge branch 'local_server' into staging_environment 2026-05-21 13:11:40 -06:00
Carter
08b3db1cd9 removed default whitelabel to prevent auth error on non-production servers 2026-05-21 12:54:34 -06:00
Carter
577de35160 can now deploy with a chosen whitelabel 2026-05-21 12:21:48 -06:00
Carter
2c77d3253b restored dedicated chat drawer crisp button 2026-05-21 11:10:10 -06:00
Carter
81c2b687fa redesigned white labeling code 2026-05-21 11:03:34 -06:00
Carter
a84c09a5c8 restored old widths 2026-05-20 15:33:40 -06:00
Carter
dad451c6af fixed alignment issues on local credentials front end (for real this time) 2026-05-20 15:25:21 -06:00
Carter
dd25770a07 fixed alignment issues on local credentials front end 2026-05-20 12:08:43 -06:00
csawatzky
acdc18ce28 Merge branch 'master' into 3d_bin 2026-05-20 11:59:08 -06:00
csawatzky
9cc7715f4c Merge branch 'staging_environment' into 3d_bin 2026-05-20 11:59:03 -06:00
csawatzky
74025290f1 created a new hook for handling fullscreen rather than using react-full-screen, hopefully that will solve the issues some users are seeing where it goes into full screnn and then immediately exits 2026-05-20 10:38:46 -06:00
csawatzky
ced3576c96 hotfix to try and fix the fullscreen bug some users are seeing, i cant test it because i can't replicate the bug 2026-05-20 10:09:23 -06:00
csawatzky
6ccc1ba263 added the alerts to the bin details part of the bin page, also fixed bug in the new alert dialog 2026-05-19 16:30:22 -06:00
Carter
0410a5ccae removing auth dependency from shared authorized ui 2026-05-19 16:19:48 -06:00
Carter
9f02b47def Merge branch 'import_warnings' into local_server 2026-05-19 16:18:42 -06:00
Carter
c549372262 added forgotten local auth file 2026-05-19 16:06:48 -06:00
Carter
e6796b1672 Merge branch 'import_warnings' into staging_environment 2026-05-19 15:42:22 -06:00
csawatzky
f72aeb802f Merge branch 'master' into 3d_bin 2026-05-19 14:02:05 -06:00
Carter
13346403e5 fixed compilation warnings caused by lazy loading modules 2026-05-19 13:25:11 -06:00
csawatzky
a65dbe8820 finished work on the table, made the legend, and hooked up the edits for the temp/moisture targets 2026-05-15 15:49:53 -06:00
csawatzky
0910dc44cf changed up the scan dispaly to show expanders properly rather than just as another sensor with no expansion line, and added the lines to the port info in the component cards 2026-05-14 13:32:53 -06:00
csawatzky
0f4d706e76 hooked up the mode change stuff for the new summary 2026-05-14 10:39:21 -06:00
csawatzky
f54d4b5375 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 2026-05-13 14:32:52 -06:00
Carter
ae9abaf9fd local login now functional 2026-05-06 18:18:57 -06:00
csawatzky
619f9b7bf4 some tweaks to the 3d bin and nodes, finished up the table view 2026-05-06 13:08:04 -06:00
Carter
71ab8ab5cb switched to master protobuf 2026-05-05 11:40:28 -06:00
csawatzky
ac57b1b0f4 removing dead link in bin visualizer when no plenum is attached to a bin 2026-05-05 09:17:22 -06:00
csawatzky
325d3ea7a0 made binTableView component, and am starting to average node levels 2026-05-04 09:25:43 -06:00
csawatzky
4b2c67dfd9 started work on the bin summary part of the new bin page, built the framework and the new 3d visualizer, still doing some work on the camera to create an overlay with a reset button 2026-05-01 13:11:12 -06:00
csawatzky
b3cf5b5e83 took out hardcoding of nodes for testing 2026-04-30 09:43:19 -06:00
csawatzky
874be1d8b7 changed the heatmap limit to use the grain height 2026-04-30 09:42:02 -06:00
csawatzky
b1c676987e updated the heatmap and added toggles 2026-04-29 15:33:35 -06:00
Carter
2d00070130 Merge branch 'staging_environment' 2026-04-29 10:21:23 -06:00
Carter
3b62d87d31 local authentication placeholder 2026-04-29 10:20:44 -06:00
csawatzky
61aa93aabd various tweaks and now trying to build heatmaps in two different ways 2026-04-28 17:00:09 -06:00
Carter
8169e280ec front end flow for bypassing auth0 on local buils 2026-04-27 14:42:56 -06:00
Carter
23de0a3bca created local deployment method that excludes crisp 2026-04-27 14:09:50 -06:00
csawatzky
aa6219f4b7 hotfix for when adding alerts from the bin page the conditions in the interaction were being showed in the converted value 2026-04-27 09:12:14 -06:00
csawatzky
6f6a062106 trying to fix overlaping temp clouds 2026-04-24 11:43:56 -06:00
csawatzky
f4d1167e4d added functions to return the node/cable clicked on all the way up to the parent 2026-04-23 15:56:59 -06:00
csawatzky
0701be2539 added the fill system for cable controlled inventory 2026-04-23 13:03:32 -06:00
csawatzky
6565b854b6 using the expiry time in the gate status for whehter its missing or not 2026-04-22 14:52:06 -06:00
Carter
5e52e096a2 build for local server and deployment script 2026-04-22 14:27:58 -06:00
csawatzky
f96d1184b1 changing date range when zooming airflow 2026-04-22 10:38:51 -06:00
csawatzky
d33c7505d2 commenting out test data 2026-04-22 09:13:30 -06:00
csawatzky
c0a3e23d67 missed the device availability stuff 2026-04-21 16:13:59 -06:00
csawatzky
90ad5f8b12 new component type 2026-04-21 15:38:14 -06:00
csawatzky
f548559a23 package updates 2026-04-21 14:49:10 -06:00
csawatzky
6cc40e159a moved from a general heatmap to a more spatial hot cold point around nodes that the density and brightness are controlled by how far past the threshold the nodes temp is 2026-04-21 12:24:04 -06:00
csawatzky
a7a8eed01e tweaking it again 2026-04-16 16:51:43 -06:00
csawatzky
239e69cfcb hotfix to adjust the graphs clamp on the yAxis 2026-04-16 16:29:11 -06:00
csawatzky
6c87ed03ed did some refactoring so the node and cable data is computed in the data layer so that it is accessible by anything in the bin to make way for a heatmap 2026-04-16 16:27:08 -06:00
csawatzky
ff64f8594c Merge branch 'staging_environment' into 3d_bin 2026-04-15 11:33:52 -06:00
csawatzky
f7b5a59c98 adding optional parameters to the Single set area chart to allow for clamping the graph so that large numbers do cause it to zoom etremely far out 2026-04-14 16:01:52 -06:00
csawatzky
0556fe2d8b made some changes in the bin sensors to open a new dialog when the component being removed is the last one for a device so users can remove the device from the bin along with the component 2026-04-14 15:29:59 -06:00
csawatzky
e7ab6a2d76 working on panning, currently kinda glitchy 2026-04-13 14:46:39 -06:00
csawatzky
f5db701d3c nodes and labes adjust based on zoom level, colours show based on the bins upper and lower temp limits, working on panning functionality for the camera 2026-04-13 14:46:20 -06:00
csawatzky
ee416db013 cables and noes now render in the bins, the label to show readinds appears when zooming in and inventory using fill percent works, still need to work on top node fill level for cable inventory control, and node heatmap/gradient similar to the bin SVG 2026-04-10 16:22:48 -06:00
csawatzky
da5ef99a3e added the intellifarms branding to the whilelabel 2026-04-08 11:37:48 -06:00
csawatzky
cb6609bdb1 built out a simple framework for building 3d objects
an orbital camera and some basic shapes are used to build the 3d bin shell and the flat inventory when using manual or lidar are built
2026-04-08 09:39:42 -06:00
csawatzky
605752ae02 adjustments to navigation to use its own boolean rather than piggyback off adaptive ag 2026-04-02 12:04:53 -06:00
csawatzky
89a83d04dd set up the bones for the whitelabel, just need to generate the icons from a logo once we get it 2026-04-02 11:43:43 -06:00
csawatzky
3d1c11646f updating the readme steps for creating a new whitelabel 2026-04-01 16:08:11 -06:00
csawatzky
bd15f4a15f added the checkbox in the task settings when creating a task as an object through keys and types to share the task to the team or user automatically when it is created 2026-04-01 15:07:06 -06:00
csawatzky
324ce7807f missed some more uses of measurement describer 2026-04-01 11:05:21 -06:00
csawatzky
52db64a8c6 missed a file 2026-04-01 11:04:33 -06:00
csawatzky
52c8a02af3 with changing unit preferences and such to use the user object and not local storage, the decriber needed to be updated as well and i missed that part, so now describeMeasurement takes in an optional user and uses its settings for the unit and such and when not passed in will use the defaults 2026-04-01 10:42:16 -06:00
csawatzky
9d41499c27 added an optional param to the object users and object teams to use a button with a label and use the share icon when it isn't there 2026-03-31 15:37:56 -06:00
csawatzky
186604dccf final adjustments to the field task list and the field drawer and field page using the tasks with the permissions in the correct way 2026-03-31 13:37:49 -06:00
csawatzky
8633c197ab now passing the kind for share object by key in order to be able to share things to ther objects and not just users/teams 2026-03-27 14:35:02 -06:00
csawatzky
777089e8c4 Merge branch 'staging_environment' into get_permissions_fix 2026-03-26 14:30:46 -06:00
csawatzky
cabc0146ed replaced uses of getGrainUnit to the users grainUnit function to pull form logged in users settings rather than local storage 2026-03-26 12:47:16 -06:00
csawatzky
dc54f99abd had cursor replace uses of getDistanceUnit with using the distance unit in the users settings so that it no longer relies on local storage 2026-03-26 09:43:39 -06:00
csawatzky
c6bb7aa24a added the functions for getting preferred units to the user model 2026-03-25 16:24:32 -06:00
csawatzky
a2944d7a60 refactor: replace deprecated getPressureUnit and getTemperatureUnit functions with user model methods
Updated the GateDeviceInteraction and MeasurementDescriber components to utilize user.pressureUnit() instead of the deprecated getPressureUnit() function. Added deprecation comments in units.ts for clarity on the transition to User.tempUnit() and User.pressureUnit().
2026-03-25 16:23:56 -06:00
csawatzky
ccabbbbd17 updating the colSpan in the custom element to be set based on the number of columns in the table so that it spans the full table without compressing the column below 2026-03-25 15:49:20 -06:00
csawatzky
ce41a2dc05 replacing the getTemperatureUnit function in units that uses the local storage with a function in the user model to just get it from the user settings 2026-03-25 15:45:59 -06:00
csawatzky
a2bc841510 pushing this stuff up so carter can take a look 2026-03-23 14:57:23 -06:00
Carter
a288c9503a switched to status websocket instead of the dead full device websocket 2026-03-23 12:18:51 -06:00
Carter
d6e3fe7a16 fixed bug causing websockets to reconnect after leaving a page 2026-03-23 11:05:38 -06:00
Carter
72afd06309 chore(ui): sync ResponsiveTable from dev_environment
Made-with: Cursor
2026-03-20 13:10:32 -06:00
Carter
99804a2136 chore(ui): restore PulseBox + status cards from dev_environment
Pulse animation (box-shadow ring, dual-pulse timing, multi-color via
onAnimationIteration), StatusSen5x/StatusDust readingsWrapper + timestamp
layout, matching dev_environment.

Made-with: Cursor
2026-03-20 13:08:27 -06:00
Carter
211c58c2e9 Merge branch 'websocket_context' into staging_environment 2026-03-20 12:23:01 -06:00
Carter
a901c12ebe fixed sen5x status card formatting with timestamp 2026-03-20 12:22:52 -06:00
Carter
9a06ca8867 Merge branch 'websocket_context' into staging_environment 2026-03-20 11:54:40 -06:00
Carter
f23a5d559d added forgotten base url file 2026-03-20 11:54:31 -06:00
Carter
587fdd455e Merge branch 'websocket_context' into staging_environment 2026-03-20 11:50:17 -06:00
Carter
7767110982 fixed duplicate v1 in url 2026-03-20 11:50:07 -06:00
Carter
7d9f3003b9 Merge branch 'websocket_context' into staging_environment 2026-03-20 11:34:59 -06:00
Carter
13e2c18064 enabled web sockets again to test them on staging 2026-03-20 11:34:49 -06:00
Carter
46ce5b6720 Merge branch 'websocket_context' into staging_environment 2026-03-20 11:17:08 -06:00
Carter
31f7e8ee07 fixed devices page websocket context 2026-03-20 11:16:59 -06:00
Carter
fe54da084c back ported master patches, switch to staging proto, updated proto 2026-03-19 13:56:29 -06:00
Carter
25973506b1 temporarily gettin rid of web sockets 2026-03-19 12:50:23 -06:00
csawatzky
7e79d0859b changed the sample size for the delta temp graph 2026-03-19 12:43:51 -06:00
csawatzky
d3a5c24741 updated get permissions to take in the keys and types to get the correct permissions using the context chain 2026-03-18 14:38:25 -06:00
csawatzky
2c28588861 proto update 2026-03-17 15:27:05 -06:00
csawatzky
63bba4f74e added keys and types to the note api for adding and loading notes and chats 2026-03-17 14:46:45 -06:00
266 changed files with 15389 additions and 2877 deletions

11
.env
View file

@ -7,6 +7,7 @@ VITE_AUTH0_CLIENT_DOMAIN=brandxtech.auth0.com
VITE_AUTH0_AUDIENCE=api.brandxtech.ca
VITE_AUTH0_DEV_CLIENT_ID=dzJTpqIeMA4Rwk4xujtwPbAO3TY32bM1
VITE_AUTH0_STAGING_CLIENT_ID=3ib460VvLwdeyse5iUSQfxkVdQaUmphP
VITE_AUTH0_STREAMLINE_CLIENT_ID=HwUV0hHNdVvU96zuMBTAU8i7nFdwwgIX
VITE_AUTH0_BXT_CLIENT_ID=sLnqOu40uWfQT1lYSDYj2wYmlLEHRB74
VITE_AUTH0_ADAPTIVE_AGRICULTURE_CLIENT_ID=5pUCkl2SfogkWmM244UDcLEUOp8EFdHd
@ -14,9 +15,19 @@ VITE_AUTH0_ADAPTIVE_CONSTRUCTION_CLIENT_ID=32rABabJzXRvJiWivTmeKFgwFiqh4ok7
VITE_AUTH0_AEROGROW_CLIENT_ID=KHl9ooUt1nia1RYw5n224dyggCXdbsSd
VITE_AUTH0_MIVENT_CLIENT_ID=VNALE7RW6l3dY5uYcxgwElZV0lcT25Fg
VITE_AUTH0_OMNIAIR_CLIENT_ID=IblmarD8wFafiD6doxTmOHQ6Bx3L9wWl
VITE_AUTH0_INTELLIFARMS_CLIENT_ID=RYtuAyOcB4DSaaqJMLDMf3pV8SFY9PdY
#Crisp
VITE_CRISP_WEBSITE_ID=80170383-b426-43c0-8f66-8e20a05bcdce
#Branding (Default theme)
VITE_APP_WEBSITE_TITLE="Adaptive Dashboard"
VITE_APP_PRIMARY_COLOUR=blue
VITE_APP_SECONDARY_COLOUR=blueGrey
VITE_APP_SIGNATURE_COLOUR="#323232"
# Live device/component WebSocket streams (must match string "true" in code)
VITE_ENABLE_WEBSOCKETS=true
# Default whitelabel; overwrite in .env.local
#VITE_WHITELABEL=adaptive-ag

View file

@ -18,31 +18,36 @@ Steps to add a new white label
- add a `CNAME` record pointing their custom subdomain (ex: dashboard.example.com) to brandxtech.ca
- add a login button on their website point to `/login` of their custom subdomain (ex: dashboard.example.com/login)
It is recommended to start with the Auth0 stage once everything from the client has been recieved as things like the client ID will be needed in other stages
<!-- frontend steps -->
2. Create a PWA folder in `public` that is named after the client and generate PWA assets using [this](https://realfavicongenerator.net/) website (use the company logo but you might need to use a custom favicon depending on the result)
Frontend Steps
1. Create a PWA folder in `public` that is named after the client and generate PWA assets using [this](https://realfavicongenerator.net/) website (use the company logo but you might need to use a custom favicon depending on the result)
- https://maskable.app/ also create a maskable icon for PWA
- create a 512x512 png as well for PWA
3. Create a white-label branding folder in `src/assets/whitelabels` and create a `darkLogo.png` and `lightLogo.png` from the client's logo
4. In `src/services/whiteLabel.ts`, add a new `Whitelabel` instance with the required fields and add its hostname mapping to the `whitelabels` map (it is very similar to step one but had some constraints when dealing with the `public` folder)
5. If there are any pages specific to the whitelabel be sure to update the side and bottom navigator
2. Create a white-label branding folder in `src/assets/whitelabels` and create a `darkLogo.png` and `lightLogo.png` from the client's logo
3. In `src/services/whiteLabel.ts`, add a new `Whitelabel` instance with the required fields and add its hostname mapping to the `whitelabels` map
4. If there are any pages specific to the whitelabel be sure to update the side and bottom navigator
<!-- backend steps -->
6. In `dynamic-config.prod.yml`, add a router entry for the new whitelabel
7. In `pond/http/server.go`, add the sub-domain to the `allowedOrigins`
8. In `pond/whitelabel.go`, add an entry for the new white label
Backend Steps
1. In `dynamic-config.prod.yml`, add a router entry for the new whitelabel
2. In `pond/http/server.go`, add the sub-domain to the `allowedOrigins`
3. In `pond/whitelabel.go`, add an entry for the new white label
7. Create a new `Auth0` application for the client
<!-- Auth steps -->
Auth0 Steps
1. Create a new Single Page App on Auth0 using React as the tech
2. Once you have the client ID add it to your .env file in the frontend
3. Adjust settings accordingly in the new application
- use a image bucket like [postimg](https://postimages.org/) to store their company logo and favicon
- add a whitelabel logo
- allow necessary `origins`, `callback URLs`, and `CORS domains` to the application settings (see the default application as a guideline)
- allow necessary `origins`, `callback URLs`, and `CORS domains` to the application settings (see other applications/whitelabels as a guideline)
- customize the Universal Login page to handle the new whitelabel
- check if the client ID matches this client's Auth0 application
- change the Universal Login theme using white-label colours and images stored in a bucket (ex: [postimg](https://postimages.org/))
8. Enable the white-label sub-domain for CORS in the backend
- in [http.go](https://gitlab.com/brandx/backend/blob/master/pond/http.go), add the white-label sub-domain to the `AllowedOrigins` list
## Expanding the ESLint configuration

67
deploy.sh Executable file
View file

@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
# Load .env.local if it exists
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$SCRIPT_DIR/.env.local"
if [[ -f "$ENV_FILE" ]]; then
# Export only the vars we care about, ignoring comments and blank lines
set -o allexport
# shellcheck source=/dev/null
source <(grep -E '^(DEPLOY_USER|DEPLOY_HOST|DEPLOY_SSH_KEY)=' "$ENV_FILE")
set +o allexport
fi
# Prompt for any missing values
if [[ -z "${DEPLOY_USER:-}" ]]; then
read -rp "SSH username: " DEPLOY_USER
fi
if [[ -z "${DEPLOY_HOST:-}" ]]; then
read -rp "Server IP/hostname: " DEPLOY_HOST
fi
if [[ -z "${DEPLOY_SSH_KEY:-}" ]]; then
read -rp "SSH key path [~/.ssh/id_ed25519]: " DEPLOY_SSH_KEY
DEPLOY_SSH_KEY="${DEPLOY_SSH_KEY:-~/.ssh/id_ed25519}"
fi
SSH_OPTS="-i $DEPLOY_SSH_KEY"
echo "→ Deploying to $DEPLOY_USER@$DEPLOY_HOST using key $DEPLOY_SSH_KEY"
# 1. Build frontend for LAN / self-contained hosts (indexLocal.html via vite --mode localnet)
# Pass VITE_WHITELABEL if provided as an argument; otherwise Vite reads it from .env
(
cd "$SCRIPT_DIR"
if [[ -n "${1:-}" ]]; then
echo "→ Whitelabel override: $1"
VITE_WHITELABEL="$1" npm run build:local
else
npm run build:local
fi
)
# Fail if the bundle still embeds Crisp (wrong vite mode or stale build/)
INDEX_HTML="$SCRIPT_DIR/build/index.html"
if [[ ! -f "$INDEX_HTML" ]]; then
echo "error: missing $INDEX_HTML after build:local" >&2
exit 1
fi
# 2. Build container image
docker build -t webui:local -f Dockerfile .
# 3. Transfer image
docker save webui:local | ssh $SSH_OPTS "$DEPLOY_USER@$DEPLOY_HOST" docker load
# 4. SCP compose file
scp $SSH_OPTS docker-compose.local.yml "$DEPLOY_USER@$DEPLOY_HOST:~/"
# 5. Deploy — then force service recreate. Swarm often keeps the old task when the tag stays
# webui:local after docker load, so without --force you still see the previous HTML/JS.
STACK_NAME="${LOCAL_DOCKER_STACK_NAME:-webui}"
SERVICE_NAME="${STACK_NAME}_webui"
ssh $SSH_OPTS "$DEPLOY_USER@$DEPLOY_HOST" \
"docker stack deploy -c docker-compose.local.yml $STACK_NAME && docker service update --force $SERVICE_NAME"

18
docker-compose.local.yml Normal file
View file

@ -0,0 +1,18 @@
version: "3.6"
services:
webui:
image: webui:local
ports:
- "8080:80"
stop_signal: SIGQUIT
deploy:
replicas: 1
update_config:
order: start-first
healthcheck:
test: ["CMD", "curl", "-f", "localhost:80/health"]
interval: 1m
timeout: 10s
retries: 3
start_period: 30s

16
indexLocal.html Normal file
View file

@ -0,0 +1,16 @@
<!-- LAN / self-contained deploy shell: use with `vite --mode localnet` (e.g. build:local, start-local).
Minimal HTML, no third-party bootstraps that call the public internet (Crisp, etc.). -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" id="favicon-link" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title id="title-id">Adaptive Dashboard</title>
<link rel="manifest" id="manifest-link" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/app/main.tsx"></script>
</body>
</html>

1919
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,10 +5,10 @@
"type": "module",
"scripts": {
"start": "VITE_AUTH0_CLIENT_ID=5pUCkl2SfogkWmM244UDcLEUOp8EFdHd VITE_AUTH0_AUDIENCE=api.brandxtech.ca VITE_APP_API_URL=https://api.brandxtech.ca/v1 VITE_APP_WS_URL=ws://api.brandxtech.ca/v1/live vite",
"start-local": "VITE_AUTH0_CLIENT_ID=dzJTpqIeMA4Rwk4xujtwPbAO3TY32bM1 VITE_AUTH0_AUDIENCE=bxt-dev.api.adaptiveagriculture.ca VITE_APP_API_URL=http://localhost:50052/v1 VITE_APP_WS_URL=ws://localhost:50052/v1/live VITE_APP_BILLING_URL=http://localhost:50053/v1 VITE_APP_RECLUSE_URL=http://localhost:50054/v1 VITE_APP_GITLAB_URL=http://localhost:50055/v1 vite",
"start-local": "VITE_AUTH0_CLIENT_ID=dzJTpqIeMA4Rwk4xujtwPbAO3TY32bM1 VITE_AUTH0_AUDIENCE=bxt-dev.api.adaptiveagriculture.ca VITE_APP_API_URL=http://localhost:50052/v1 VITE_APP_WS_URL=ws://localhost:50052/v1/live VITE_APP_BILLING_URL=http://localhost:50053/v1 VITE_APP_RECLUSE_URL=http://localhost:50054/v1 VITE_APP_GITLAB_URL=http://localhost:50055/v1 vite --mode localnet",
"start-dev": "VITE_AUTH0_CLIENT_ID=dzJTpqIeMA4Rwk4xujtwPbAO3TY32bM1 VITE_APP_API_URL=https://bxt-dev.api.adaptiveagriculture.ca/v1 VITE_AUTH0_CLIENT_DOMAIN=brandxtech.auth0.com VITE_AUTH0_AUDIENCE=bxt-dev.api.adaptiveagriculture.ca VITE_AUTH0_DEV_CLIENT_ID=dzJTpqIeMA4Rwk4xujtwPbAO3TY32bM1 vite",
"start-streamline": "VITE_AUTH0_CLIENT_ID=HwUV0hHNdVvU96zuMBTAU8i7nFdwwgIX VITE_APP_API_URL=https://streamline.api.adaptiveagriculture.ca/v1 VITE_AUTH0_CLIENT_DOMAIN=brandxtech.auth0.com VITE_AUTH0_AUDIENCE=streamline.api.adaptiveagriculture.ca vite",
"start-staging": "VITE_LOCAL_STAGING=true VITE_AUTH0_CLIENT_ID=3ib460VvLwdeyse5iUSQfxkVdQaUmphP VITE_AUTH0_AUDIENCE=stagingapi.brandxtech.ca VITE_AUTH0_CLIENT_DOMAIN=adaptivestaging.us.auth0.com VITE_APP_API_URL=https://stagingapi.brandxtech.ca/v1 VITE_APP_WS_URL=ws://stagingapi.brandxtech.ca/v1/live VITE_APP_AUTH0_CLIENT_DOMAIN=adaptivestaging.us.auth0.com VITE_APP_AUTH0_AUDIENCE=stagingapi.brandxtech.ca vite",
"start-streamline": "VITE_WHITELABEL=streamline VITE_AUTH0_CLIENT_ID=HwUV0hHNdVvU96zuMBTAU8i7nFdwwgIX VITE_APP_API_URL=https://streamline.api.adaptiveagriculture.ca/v1 VITE_AUTH0_CLIENT_DOMAIN=brandxtech.auth0.com VITE_AUTH0_AUDIENCE=streamline.api.adaptiveagriculture.ca vite",
"start-staging": "VITE_WHITELABEL=staging VITE_AUTH0_CLIENT_ID=3ib460VvLwdeyse5iUSQfxkVdQaUmphP VITE_AUTH0_AUDIENCE=stagingapi.brandxtech.ca VITE_AUTH0_CLIENT_DOMAIN=adaptivestaging.us.auth0.com VITE_APP_API_URL=https://stagingapi.brandxtech.ca/v1 VITE_APP_WS_URL=ws://stagingapi.brandxtech.ca/v1/live vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
@ -16,6 +16,7 @@
"build:development": "VITE_CRISP_WEBSITE_ID={CRISP_WEBSITE_ID} VITE_AUTH0_CLIENT_ID=dzJTpqIeMA4Rwk4xujtwPbAO3TY32bM1 VITE_AUTH0_AUDIENCE=bxt-dev.api.adaptiveagriculture.ca VITE_APP_API_URL=https://bxt-dev.api.adaptiveagriculture.ca/v1 NODE_OPTIONS=--max_old_space_size=4096 VITE_APP_GOOGLE_API_KEY=${GOOGLE_API_KEY} VITE_APP_STRIPE_PUBLIC_KEY=${STRIPE_PUBLIC_KEY_DEVELOPMENT} VITE_MAPBOX_ACCESS_TOKEN=${MAPBOX_ACCESS_TOKEN} VITE_CNHI_CLIENT_ID=${CNHI_CLIENT_ID} VITE_CNHI_AUTHORIZE_URL=${CNHI_AUTHORIZE_URL} VITE_CNHI_REDIRECT_URI=${CNHI_REDIRECT_URI} VITE_CNHI_SCOPES=${CNHI_SCOPES} VITE_CNHI_CONNECTION=${CNHI_CONNECTION} VITE_CNHI_AUDIENCE=${CNHI_AUDIENCE} VITE_APP_IMAGE4IO_USERNAME=${IMAGE4IO_USERNAME} VITE_APP_IMAGE4IO_PASSWORD=${IMAGE4IO_PASSWORD} VITE_JD_CLIENT_ID=${JD_CLIENT_ID} VITE_JD_AUTHORIZE_URL=${JD_AUTHORIZE_URL} VITE_JD_REDIRECT_URI=${JD_REDIRECT_URI} VITE_JD_SCOPES=${JD_SCOPES} VITE_JD_STATE=${JD_STATE} VITE_OPEN_WEATHERMAP=${OPEN_WEATHERMAP} vite build",
"build:production": "VITE_CRISP_WEBSITE_ID={CRISP_WEBSITE_ID} VITE_AUTH0_CLIENT_ID=5pUCkl2SfogkWmM244UDcLEUOp8EFdHd VITE_AUTH0_CLIENT_DOMAIN=brandxtech.auth0.com VITE_AUTH0_AUDIENCE=api.brandxtech.ca VITE_APP_API_URL=https://api.brandxtech.ca/v1 NODE_OPTIONS=--max_old_space_size=4096 VITE_APP_GOOGLE_API_KEY=${GOOGLE_API_KEY} VITE_APP_STRIPE_PUBLIC_KEY=${STRIPE_PUBLIC_KEY_PRODUCTION} VITE_MAPBOX_ACCESS_TOKEN=${MAPBOX_ACCESS_TOKEN} VITE_CNHI_CLIENT_ID=${CNHI_CLIENT_ID} VITE_CNHI_AUTHORIZE_URL=${CNHI_AUTHORIZE_URL} VITE_CNHI_REDIRECT_URI=${CNHI_REDIRECT_URI} VITE_CNHI_SCOPES=${CNHI_SCOPES} VITE_CNHI_CONNECTION=${CNHI_CONNECTION} VITE_CNHI_AUDIENCE=${CNHI_AUDIENCE} VITE_APP_IMAGE4IO_USERNAME=${IMAGE4IO_USERNAME} VITE_APP_IMAGE4IO_PASSWORD=${IMAGE4IO_PASSWORD} VITE_JD_CLIENT_ID=${JD_CLIENT_ID} VITE_JD_AUTHORIZE_URL=${JD_AUTHORIZE_URL} VITE_JD_REDIRECT_URI=${JD_REDIRECT_URI} VITE_JD_SCOPES=${JD_SCOPES} VITE_JD_STATE=${JD_STATE} VITE_OPEN_WEATHERMAP=${OPEN_WEATHERMAP} vite build",
"build:streamline": "VITE_CRISP_WEBSITE_ID={CRISP_WEBSITE_ID} VITE_AUTH0_CLIENT_ID=HwUV0hHNdVvU96zuMBTAU8i7nFdwwgIX VITE_AUTH0_CLIENT_DOMAIN=brandxtech.auth0.com VITE_AUTH0_AUDIENCE=streamline.api.adaptiveagriculture.ca VITE_APP_API_URL=https://streamline.api.adaptiveagriculture.ca/v1 NODE_OPTIONS=--max_old_space_size=4096 VITE_APP_GOOGLE_API_KEY=${GOOGLE_API_KEY} VITE_APP_STRIPE_PUBLIC_KEY=${STRIPE_PUBLIC_KEY_PRODUCTION} VITE_MAPBOX_ACCESS_TOKEN=${MAPBOX_ACCESS_TOKEN} VITE_CNHI_CLIENT_ID=${CNHI_CLIENT_ID} VITE_CNHI_AUTHORIZE_URL=${CNHI_AUTHORIZE_URL} VITE_CNHI_REDIRECT_URI=${CNHI_REDIRECT_URI} VITE_CNHI_SCOPES=${CNHI_SCOPES} VITE_CNHI_CONNECTION=${CNHI_CONNECTION} VITE_CNHI_AUDIENCE=${CNHI_AUDIENCE} VITE_APP_IMAGE4IO_USERNAME=${IMAGE4IO_USERNAME} VITE_APP_IMAGE4IO_PASSWORD=${IMAGE4IO_PASSWORD} VITE_JD_CLIENT_ID=${JD_CLIENT_ID} VITE_JD_AUTHORIZE_URL=${JD_AUTHORIZE_URL} VITE_JD_REDIRECT_URI=${JD_REDIRECT_URI} VITE_JD_SCOPES=${JD_SCOPES} VITE_JD_STATE=${JD_STATE} VITE_OPEN_WEATHERMAP=${OPEN_WEATHERMAP} vite build",
"build:local": "VITE_CRISP_WEBSITE_ID= VITE_AUTH0_CLIENT_ID=local VITE_AUTH0_AUDIENCE=local VITE_AUTH0_CLIENT_DOMAIN=local VITE_APP_API_URL=http://172.16.1.20:50052/v1 VITE_APP_WS_URL=ws://172.16.1.20:50052/v1/live NODE_OPTIONS=--max_old_space_size=4096 vite build --mode localnet",
"build:offline": "npx env-cmd offline,whitelabel npm run build",
"test": "vitest"
},
@ -37,10 +38,13 @@
"@mui/styles": "^6.1.6",
"@mui/x-date-pickers": "^7.26.0",
"@react-pdf/renderer": "^4.3.0",
"@react-three/drei": "^9.105.6",
"@react-three/fiber": "^8.18.0",
"@sentry/react": "^8.38.0",
"@turf/area": "^7.2.0",
"@turf/turf": "^7.2.0",
"@types/classnames": "^2.3.0",
"@types/three": "^0.183.1",
"axios": "^1.7.7",
"crisp-sdk-web": "^1.0.26",
"dayjs": "^1.11.13",
@ -64,7 +68,6 @@
"react-dom": "^18.3.1",
"react-emoji-render": "^2.0.1",
"react-error-boundary": "^5.0.0",
"react-full-screen": "^1.1.1",
"react-horizontal-scrolling-menu": "^7.1.1",
"react-infinite-scroller": "^1.2.6",
"react-joyride": "^2.9.3",
@ -74,6 +77,7 @@
"react-virtualized-auto-sizer": "^1.0.25",
"recharts": "^2.15.1",
"semver": "^7.7.1",
"three": "^0.152.2",
"victory": "^37.3.6",
"weather-icons-react": "^1.2.0"
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#ffffff</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View file

@ -0,0 +1,19 @@
{
"name": "Intellifarms",
"short_name": "Intellifarms",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<image href="Intellifarms_Icon.png" height="512" width="512"/>
</svg>

After

Width:  |  Height:  |  Size: 134 B

View file

@ -0,0 +1,90 @@
import { useThree } from "@react-three/fiber";
import { useEffect, useRef, useState } from "react";
import ReactDOM from "react-dom";
interface Props {
showResetButton?: boolean;
onReset?: () => void;
}
/**
* Renders camera control UI buttons as a DOM overlay on top of the R3F canvas.
* Must be placed inside a <Canvas> so it has access to useThree().
*
* Uses a portal into a div that is absolutely positioned over the canvas element,
* so the buttons sit in 2D screen space rather than 3D world space.
*/
export default function CameraOverlay({ showResetButton, onReset }: Props) {
const { gl } = useThree();
const [container, setContainer] = useState<HTMLDivElement | null>(null);
useEffect(() => {
const canvas = gl.domElement;
const parent = canvas.parentElement;
if (!parent) return;
// Ensure the parent is positioned so absolute children are relative to it
const parentPosition = getComputedStyle(parent).position;
if (parentPosition === "static") {
parent.style.position = "relative";
}
const div = document.createElement("div");
div.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
`;
parent.appendChild(div);
setContainer(div);
return () => {
parent.removeChild(div);
};
}, [gl]);
if (!container) return null;
return ReactDOM.createPortal(
<div style={{
position: "absolute",
bottom: 12,
right: 12,
display: "flex",
flexDirection: "column",
gap: 8,
pointerEvents: "auto",
}}>
{showResetButton && (
<button
onClick={onReset}
title="Reset camera"
style={{
width: 36,
height: 36,
borderRadius: 6,
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(0,0,0,0.45)",
color: "white",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 16,
backdropFilter: "blur(4px)",
transition: "background 0.15s",
}}
onMouseEnter={e => (e.currentTarget.style.background = "rgba(0,0,0,0.7)")}
onMouseLeave={e => (e.currentTarget.style.background = "rgba(0,0,0,0.45)")}
>
</button>
)}
</div>,
container
);
}

View file

@ -0,0 +1,329 @@
import React from "react";
import { useThree } from "@react-three/fiber";
import { useEffect, useRef } from "react";
import { Vector2, Vector3 } from "three";
interface CameraTarget {
x: number;
y: number;
z: number;
}
interface Props {
target?: CameraTarget;
clampVerticalRotation?: boolean;
/**
* The minimum vertical rotation in radians
* @default 0.01
*/
minPhi?: number;
/**
* The maximum vertical rotation in radians
* @default Math.PI - 0.01
*/
maxPhi?: number;
/**
* Starting camera distance from the target.
* Pass a value computed from the bin dimensions so the whole bin
* fits in frame on first render. If omitted defaults to 10.
*/
initialRadius?: number;
/**
* Maximum zoom-out distance. Defaults to 30 but should be set
* larger when initialRadius is large.
*/
maxRadius?: number;
/**
* used to control the offset of the target from the center that the camera rotates around
*/
offset?: { x: number; y: number; z: number };
viewOffset?: number;
/**
* Called when the camera is reset to its initial position.
* OrbitCameraControls manages the reset internally use this to
* trigger it from outside (e.g. from CameraOverlay's reset button).
* Wire it by passing a ref setter: onReset={fn => resetFn.current = fn}
*/
onReset?: (resetFn: () => void) => void;
}
export default function OrbitCameraControls(props: Props) {
const {
target,
clampVerticalRotation,
minPhi = 0.01,
maxPhi = Math.PI - 0.01,
offset,
viewOffset,
initialRadius,
maxRadius,
onReset,
} = props;
const { camera, gl } = useThree();
const mouse = useRef(new Vector2());
const targetRef = useRef({
x: (target?.x ?? 0) + (offset?.x ?? 0),
y: (target?.y ?? 0) + (offset?.y ?? 0),
z: (target?.z ?? 0) + (offset?.z ?? 0),
});
const isDragging = useRef(false);
const isPanning = useRef(false);
const lastPos = useRef({ x: 0, y: 0 });
const panCameraPosition = useRef(new Vector3());
const lastPinchDist = useRef(0);
// Spherical coords
const spherical = useRef({
radius: initialRadius ?? 10,
theta: 0, // horizontal angle
phi: Math.PI / 2, // vertical angle
});
useEffect(() => {
// Sync radius whenever initialRadius prop changes (e.g. after bin data loads)
spherical.current.radius = initialRadius ?? 10;
const canvas = gl.domElement;
canvas.addEventListener("contextmenu", e => e.preventDefault());
const updateCamera = () => {
const { radius, theta, phi } = spherical.current;
const x = radius * Math.sin(phi) * Math.sin(theta);
const y = radius * Math.cos(phi);
const z = radius * Math.sin(phi) * Math.cos(theta);
camera.position.set(
targetRef.current.x + x,
targetRef.current.y + y,
targetRef.current.z + z
);
camera.lookAt(
targetRef.current.x,
targetRef.current.y,
targetRef.current.z
);
// Shift the projected image without affecting orbit
const { width, height } = gl.domElement.getBoundingClientRect();
(camera as any).setViewOffset(width, height, viewOffset ?? 0, 0, width, height);
};
// Hand the reset function to the caller so they can trigger it
// (e.g. from a button in CameraOverlay) without needing an external ref.
if (onReset) {
onReset(() => {
spherical.current.radius = initialRadius ?? 10;
spherical.current.theta = 0;
spherical.current.phi = Math.PI / 2;
targetRef.current = {
x: (target?.x ?? 0) + (offset?.x ?? 0),
y: (target?.y ?? 0) + (offset?.y ?? 0),
z: (target?.z ?? 0) + (offset?.z ?? 0),
};
updateCamera();
});
}
updateCamera();
const handleMouseDown = (e: MouseEvent) => {
if (e.target !== canvas) return;
lastPos.current = { x: e.clientX, y: e.clientY };
panCameraPosition.current.copy(camera.position);
if (e.button === 0) {
isDragging.current = true;
isPanning.current = false;
}
if (e.button === 2) {
isPanning.current = true;
isDragging.current = false;
}
};
const handleMouseMove = (e: MouseEvent) => {
const deltaX = e.clientX - lastPos.current.x;
const deltaY = e.clientY - lastPos.current.y;
// ROTATE — left drag
if (isDragging.current) {
spherical.current.theta -= deltaX * 0.005;
spherical.current.phi -= deltaY * 0.005;
if (clampVerticalRotation) {
spherical.current.phi = Math.max(
minPhi,
Math.min(maxPhi, spherical.current.phi)
);
}
}
// PAN — right drag
// Uses screen-space axes derived from the camera so panning is
// correct at any angle including top-down.
if (isPanning.current) {
const distance = spherical.current.radius;
const panSpeed = 0.002 * distance;
const forward = new Vector3();
camera.getWorldDirection(forward);
// Right vector — perpendicular to forward in the horizontal plane
const right = new Vector3();
right.crossVectors(forward, new Vector3(0, 1, 0)).normalize();
// Degenerate guard: when camera is nearly straight up or down,
// fall back to camera.up
if (right.lengthSq() < 0.001) {
right.crossVectors(forward, camera.up).normalize();
}
// screenUp — perpendicular to both forward and right.
// This is the actual "up" direction on screen regardless of
// camera tilt, avoiding the zoom-while-panning bug.
const screenUp = new Vector3();
screenUp.crossVectors(right, forward).normalize();
right.multiplyScalar(-deltaX * panSpeed);
screenUp.multiplyScalar(deltaY * panSpeed);
const pan = new Vector3().addVectors(right, screenUp);
targetRef.current.x += pan.x;
targetRef.current.y += pan.y;
targetRef.current.z += pan.z;
}
lastPos.current = { x: e.clientX, y: e.clientY };
updateCamera();
};
const handleMouseUp = () => {
isDragging.current = false;
isPanning.current = false;
};
const handleWheel = (e: WheelEvent) => {
if (e.target !== canvas) return;
e.preventDefault();
spherical.current.radius += e.deltaY * 0.01;
spherical.current.radius = Math.max(
3,
Math.min(maxRadius ?? 30, spherical.current.radius)
);
updateCamera();
};
const handleTouchStart = (e: TouchEvent) => {
if (e.target !== canvas) return;
e.preventDefault();
if (e.touches.length === 1) {
isDragging.current = true;
isPanning.current = false;
lastPos.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
} else if (e.touches.length === 2) {
isDragging.current = false;
isPanning.current = true;
lastPos.current = {
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
y: (e.touches[0].clientY + e.touches[1].clientY) / 2,
};
// Store initial pinch distance for zoom
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
lastPinchDist.current = Math.sqrt(dx * dx + dy * dy);
}
};
const handleTouchMove = (e: TouchEvent) => {
e.preventDefault();
if (e.touches.length === 1 && isDragging.current) {
const deltaX = e.touches[0].clientX - lastPos.current.x;
const deltaY = e.touches[0].clientY - lastPos.current.y;
spherical.current.theta -= deltaX * 0.005;
spherical.current.phi -= deltaY * 0.005;
if (clampVerticalRotation) {
spherical.current.phi = Math.max(minPhi, Math.min(maxPhi, spherical.current.phi));
}
lastPos.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
updateCamera();
} else if (e.touches.length === 2) {
// Pinch zoom
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const dist = Math.sqrt(dx * dx + dy * dy);
const pinchDelta = lastPinchDist.current - dist;
spherical.current.radius += pinchDelta * 0.05;
spherical.current.radius = Math.max(3, Math.min(maxRadius ?? 30, spherical.current.radius));
lastPinchDist.current = dist;
// Two-finger pan
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
const panDeltaX = midX - lastPos.current.x;
const panDeltaY = midY - lastPos.current.y;
const distance = spherical.current.radius;
const panSpeed = 0.002 * distance;
const forward = new Vector3();
camera.getWorldDirection(forward);
const right = new Vector3().crossVectors(forward, new Vector3(0, 1, 0)).normalize();
if (right.lengthSq() < 0.001) right.crossVectors(forward, camera.up).normalize();
const screenUp = new Vector3().crossVectors(right, forward).normalize();
right.multiplyScalar(-panDeltaX * panSpeed);
screenUp.multiplyScalar(panDeltaY * panSpeed);
const pan = new Vector3().addVectors(right, screenUp);
targetRef.current.x += pan.x;
targetRef.current.y += pan.y;
targetRef.current.z += pan.z;
lastPos.current = { x: midX, y: midY };
updateCamera();
}
};
const handleTouchEnd = (e: TouchEvent) => {
if (e.touches.length === 0) {
isDragging.current = false;
isPanning.current = false;
} else if (e.touches.length === 1) {
// Transitioned from pinch back to single finger
isDragging.current = true;
isPanning.current = false;
lastPos.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
}
};
window.addEventListener("mousedown", handleMouseDown);
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
canvas.addEventListener("wheel", handleWheel, { passive: false });
canvas.addEventListener("touchstart", handleTouchStart, { passive: false });
canvas.addEventListener("touchmove", handleTouchMove, { passive: false });
canvas.addEventListener("touchend", handleTouchEnd);
return () => {
window.removeEventListener("mousedown", handleMouseDown);
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
canvas.removeEventListener("wheel", handleWheel);
canvas.removeEventListener("touchstart", handleTouchStart);
canvas.removeEventListener("touchmove", handleTouchMove);
canvas.removeEventListener("touchend", handleTouchEnd);
};
}, [camera, gl, target, clampVerticalRotation, minPhi, maxPhi, initialRadius, maxRadius]);
return null;
}

View file

@ -0,0 +1,34 @@
import { useMemo } from "react";
import * as THREE from "three";
import BaseMesh, { BaseMeshProps } from "../BaseMesh";
export interface CircleGeometry{
radius: number
radialSegments?: number
thetaStart?: number
thetaLength?: number
}
interface Props extends Omit<BaseMeshProps, "geometry"> {
geometry: CircleGeometry;
}
export default function Circle(props: Props) {
const { geometry } = props;
const geo = useMemo(() => {
return new THREE.CircleGeometry(
geometry.radius,
geometry.radialSegments ?? 24,
geometry.thetaStart ?? 0,
geometry.thetaLength ?? Math.PI * 2
);
}, [
geometry.radius,
geometry.radialSegments,
geometry.thetaStart,
geometry.thetaLength
]);
return <BaseMesh {...props} geometry={geo} />;
}

View file

@ -0,0 +1,43 @@
import { useMemo } from "react";
import * as THREE from "three";
import BaseMesh, { BaseMeshProps } from "../BaseMesh";
export interface ConeGeometry {
radius: number;
height: number;
radialSegments?: number;
heightSegments?: number;
openEnded?: boolean;
thetaStart?: number;
thetaLength?: number;
}
interface Props extends Omit<BaseMeshProps, "geometry"> {
geometry: ConeGeometry;
}
export default function Cone(props: Props) {
const { geometry } = props;
const geo = useMemo(() => {
return new THREE.ConeGeometry(
geometry.radius,
geometry.height,
geometry.radialSegments ?? 24,
geometry.heightSegments ?? 1,
geometry.openEnded ?? false,
geometry.thetaStart ?? 0,
geometry.thetaLength ?? Math.PI * 2
);
}, [
geometry.radius,
geometry.height,
geometry.radialSegments,
geometry.heightSegments,
geometry.openEnded,
geometry.thetaStart,
geometry.thetaLength
]);
return <BaseMesh {...props} geometry={geo} />;
}

View file

@ -0,0 +1,40 @@
import { useMemo } from "react";
import * as THREE from "three";
import BaseMesh, { BaseMeshProps } from "../BaseMesh";
export interface CubeGeometry {
height: number;
width: number;
depth: number;
widthSegments?: number;
heightSegments?: number;
depthSegments?: number;
}
interface Props extends Omit<BaseMeshProps, "geometry"> {
geometry: CubeGeometry;
}
export default function Cube(props: Props) {
const { geometry } = props;
const geo = useMemo(() => {
return new THREE.BoxGeometry(
geometry.height,
geometry.width,
geometry.depth,
geometry.widthSegments ?? 2,
geometry.heightSegments ?? 2,
geometry.depthSegments ?? 2
);
}, [
geometry.height,
geometry.width,
geometry.depth,
geometry.widthSegments,
geometry.heightSegments,
geometry.depthSegments
]);
return <BaseMesh {...props} geometry={geo} />;
}

View file

@ -0,0 +1,46 @@
import { useMemo } from "react";
import * as THREE from "three";
import BaseMesh, { BaseMeshProps } from "../BaseMesh";
export interface CylinderGeometry {
radiusTop: number;
radiusBottom: number;
height: number;
radialSegments?: number;
heightSegments?: number;
openEnded?: boolean;
thetaStart?: number;
thetaLength?: number;
}
interface Props extends Omit<BaseMeshProps, "geometry"> {
geometry: CylinderGeometry;
}
export default function Cylinder(props: Props) {
const { geometry } = props;
const geo = useMemo(() => {
return new THREE.CylinderGeometry(
geometry.radiusTop,
geometry.radiusBottom,
geometry.height,
geometry.radialSegments ?? 24,
geometry.heightSegments ?? 1,
geometry.openEnded ?? false,
geometry.thetaStart ?? 0,
geometry.thetaLength ?? Math.PI * 2
);
}, [
geometry.radiusTop,
geometry.radiusBottom,
geometry.height,
geometry.radialSegments,
geometry.heightSegments,
geometry.openEnded,
geometry.thetaStart,
geometry.thetaLength
]);
return <BaseMesh {...props} geometry={geo} />;
}

View file

@ -0,0 +1,40 @@
import { useMemo } from "react";
import * as THREE from "three";
import BaseMesh, { BaseMeshProps } from "../BaseMesh";
export interface RingGeometry{
// the radius of the ring from the center to the ouside edge, must be larger than thickness
radius: number
// the thickness of the tube, must be smaller than radius
thickness: number
radialSegments?: number
tubularSegments?: number
// central angle in radians
arc?: number
}
interface Props extends Omit<BaseMeshProps, "geometry"> {
geometry: RingGeometry;
}
export default function Ring(props: Props) {
const { geometry } = props;
const geo = useMemo(() => {
return new THREE.TorusGeometry(
geometry.radius,
geometry.thickness,
geometry.radialSegments ?? 12,
geometry.tubularSegments ?? 48,
geometry.arc ?? Math.PI * 2
);
}, [
geometry.radius,
geometry.thickness,
geometry.radialSegments,
geometry.tubularSegments,
geometry.arc
]);
return <BaseMesh {...props} geometry={geo} />;
}

View file

@ -0,0 +1,43 @@
import { useMemo } from "react";
import * as THREE from "three";
import BaseMesh, { BaseMeshProps } from "../BaseMesh";
export interface SphereGeometry {
radius: number
widthSegments?: number
heightSegments?: number
phiStart?: number
phiLength?: number
thetaStart?: number
thetaLength?: number
}
interface Props extends Omit<BaseMeshProps, "geometry"> {
geometry: SphereGeometry;
}
export default function Sphere(props: Props){
const { geometry } = props;
const geo = useMemo(() => {
return new THREE.SphereGeometry(
geometry.radius,
geometry.widthSegments,
geometry.heightSegments,
geometry.phiStart,
geometry.phiLength,
geometry.thetaStart,
geometry.thetaLength
);
}, [
geometry.radius,
geometry.widthSegments,
geometry.heightSegments,
geometry.phiStart,
geometry.phiLength,
geometry.thetaStart,
geometry.thetaLength
]);
return <BaseMesh {...props} geometry={geo} />;
}

View file

@ -0,0 +1,99 @@
import { Euler, Vector3, BufferGeometry, MaterialParameters, Side } from "three";
import { ThreeEvent } from "@react-three/fiber";
export interface BaseMeshProps {
geometry: BufferGeometry;
position?: Vector3;
rotation?: Euler;
colour?: string;
opacity?: number;
metalness?: number;
roughness?: number;
wireframe?: boolean;
frameColour?: string;
materialType?: "standard" | "basic";
materialProps?: Partial<MaterialParameters>;
materialOverride?: React.ReactNode;
side?: Side;
depthWrite?: boolean;
depthTest?: boolean;
renderOrder?: number;
onClick?: (event: ThreeEvent<MouseEvent>) => void;
}
export default function BaseMesh(props: BaseMeshProps) {
const {
geometry,
position,
rotation,
colour = "#fff",
opacity = 1,
metalness = 0,
roughness = 1,
wireframe = false,
frameColour = "black",
materialType,
materialProps,
materialOverride,
side,
depthWrite = true,
depthTest = true,
renderOrder = 0,
onClick,
} = props;
const isTransparent = opacity < 1;
const buildMaterial = () => {
if (materialOverride) return materialOverride;
switch (materialType) {
case "basic":
return (
<meshBasicMaterial
color={colour}
transparent={isTransparent}
opacity={opacity}
depthWrite={depthWrite}
depthTest={depthTest}
side={side}
{...materialProps}
/>
);
case "standard":
default:
return (
<meshStandardMaterial
color={colour}
metalness={metalness}
roughness={roughness}
transparent={isTransparent}
opacity={opacity}
depthWrite={depthWrite}
depthTest={depthTest}
side={side}
{...materialProps}
/>
);
}
};
return (
<group position={position} rotation={rotation} renderOrder={renderOrder}>
{/* Main surface */}
<mesh geometry={geometry} onClick={onClick} renderOrder={renderOrder}>
{buildMaterial()}
</mesh>
{/* Optional wireframe overlay */}
{wireframe && (
<mesh geometry={geometry} renderOrder={renderOrder}>
<meshStandardMaterial
wireframe
color={frameColour}
/>
</mesh>
)}
</group>
);
}

View file

@ -1,15 +1,20 @@
import { Auth0Provider } from '@auth0/auth0-react'
import { or } from '../utils/types'
import { isAuth0Configured, isAuth0SpaOriginAllowed, shouldMountAuth0Provider } from '../utils/auth0Config'
import AuthWrapper from '../providers/auth'
import HTTPProvider from 'providers/http'
import { useState } from 'react'
import LoadingScreen from './LoadingScreen'
import LocalAuthPlaceholder from './LocalAuthPlaceholder'
import UserWrapper from './UserWrapper'
import { getWhitelabel } from 'services/whiteLabel'
import { AppThemeProvider } from 'theme/AppThemeProvider'
import { LocalAuthProvider, Auth0AuthBridge } from '../providers/authContext'
function App() {
const [token, setToken] = useState<string | undefined>(undefined)
const [token, setToken] = useState<string | undefined>(() => {
return localStorage.getItem('local_auth_token') || undefined
})
const whiteLabel = getWhitelabel()
const manifestPath = "/" + whiteLabel.name.replace(/\s/g, "") + "/manifest.json"
@ -54,6 +59,29 @@ function App() {
"/libracart"
]
if (!shouldMountAuth0Provider()) {
const placeholderReason =
isAuth0Configured() && !isAuth0SpaOriginAllowed() ? 'insecure_origin' : 'unconfigured'
if (!token) {
return (
<AppThemeProvider>
<LocalAuthPlaceholder reason={placeholderReason} setToken={setToken} />
</AppThemeProvider>
)
}
return (
<AppThemeProvider>
<LocalAuthProvider token={token}>
<HTTPProvider token={token}>
<UserWrapper token={token} />
</HTTPProvider>
</LocalAuthProvider>
</AppThemeProvider>
)
}
return (
<AppThemeProvider>
<Auth0Provider
@ -70,9 +98,11 @@ function App() {
{/* <CssBaseline /> */}
<AuthWrapper setToken={setToken}>
{ token ?
<HTTPProvider token={token}>
<UserWrapper token={token} />
</HTTPProvider>
<Auth0AuthBridge>
<HTTPProvider token={token}>
<UserWrapper token={token} />
</HTTPProvider>
</Auth0AuthBridge>
:
<LoadingScreen
message='Loading user profile'

View file

@ -16,7 +16,7 @@ const useStyles = makeStyles((theme: Theme) => {
appBar: {
zIndex: theme.zIndex.drawer + 1,
backgroundImage: "none", // This prevents de-saturation of header in dark mode
backgroundColor: theme.palette.mode === "light" ? "#3b3b3b" : "#272727"
backgroundColor: theme.palette.header.main
},
toolbar: {
marginLeft: theme.spacing(0.5),

View file

@ -0,0 +1,141 @@
import { Box, Button, CssBaseline, Paper, Stack, TextField, Typography } from '@mui/material'
import { getName } from 'services/whiteLabel'
import { useState } from 'react'
import axios from 'axios'
export type LocalAuthPlaceholderReason = 'unconfigured' | 'insecure_origin'
interface Props {
reason?: LocalAuthPlaceholderReason
setToken?: (token: string) => void
}
export default function LocalAuthPlaceholder(props: Props) {
const { reason = 'unconfigured', setToken } = props
const productName = getName()
const [mode, setMode] = useState<'login' | 'signup'>('login')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [name, setName] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const apiUrl = import.meta.env.VITE_APP_API_URL
const handleSignup = async () => {
setError('')
setLoading(true)
try {
const resp = await axios.post(`${apiUrl}/local-auth/signup`, { email, password, name })
const token = resp.data.token
if (token && setToken) {
localStorage.setItem('local_auth_token', token)
setToken(token)
}
} catch (err: any) {
setError(err.response?.data?.error || 'Signup failed')
} finally {
setLoading(false)
}
}
const handleLogin = async () => {
setError('')
setLoading(true)
try {
const resp = await axios.post(`${apiUrl}/local-auth/login`, { email, password })
const token = resp.data.token
if (token && setToken) {
localStorage.setItem('local_auth_token', token)
setToken(token)
}
} catch (err: any) {
setError(err.response?.data?.error || 'Login failed')
} finally {
setLoading(false)
}
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (mode === 'signup') {
handleSignup()
} else {
handleLogin()
}
}
const explanation =
reason === 'insecure_origin'
? 'This URL is not a secure context for cloud sign-in. Use local account sign-in instead.'
: 'Local account sign-in for this deployment.'
return (
<>
<CssBaseline />
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'background.default',
p: 2,
}}
>
<Paper elevation={2} sx={{ maxWidth: 420, width: '100%', p: 4 }}>
<form onSubmit={handleSubmit}>
<Stack spacing={2} alignItems="stretch">
<Typography variant="h5" component="h1">
{productName}
</Typography>
<Typography variant="body2" color="text.secondary">
{explanation}
</Typography>
{mode === 'signup' && (
<TextField
label="Name"
value={name}
onChange={e => setName(e.target.value)}
fullWidth
/>
)}
<TextField
label="Username or Email"
type="text"
value={email}
onChange={e => setEmail(e.target.value)}
required
fullWidth
/>
<TextField
label="Password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
fullWidth
/>
{error && (
<Typography variant="body2" color="error">
{error}
</Typography>
)}
<Button variant="contained" size="large" type="submit" disabled={loading}>
{mode === 'signup' ? 'Sign up' : 'Log in'}
</Button>
<Button
variant="text"
size="small"
onClick={() => { setMode(mode === 'login' ? 'signup' : 'login'); setError('') }}
>
{mode === 'login' ? 'Need an account? Sign up' : 'Already have an account? Log in'}
</Button>
</Stack>
</form>
</Paper>
</Box>
</>
)
}

View file

@ -1,20 +1,20 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import './App.css'
import { useUserAPI } from '../providers/pond/userAPI'
import { useAuth0 } from '@auth0/auth0-react'
import { or } from '../utils/types'
import LoadingScreen from './LoadingScreen'
import NavigationContainer from '../navigation/NavigationContainer'
import { GlobalState, GlobalStateAction, StateProvider } from '../providers/StateContainer'
import { User } from '../models/user'
import { Team } from '../models/team'
import { getWhitelabel } from '../services/whiteLabel'
import { makeStyles } from '@mui/styles'
import { CssBaseline, Theme } from '@mui/material'
import { AppThemeProvider } from 'theme/AppThemeProvider'
import HTTPProvider from 'providers/http'
import { Crisp } from "crisp-sdk-web";
import { useMobile, useSnackbar } from 'hooks'
import { initCrisp } from '../chat/CrispChat'
import { initCrisp, isCrispEnabled } from '../chat/CrispChat'
import { useTeamAPI } from '../providers/pond/teamAPI'
// import FirmwareLoader from './FirmwareLoader'
const reducer = (state: GlobalState, action: GlobalStateAction): GlobalState => {
@ -63,26 +63,43 @@ export default function UserWrapper(props: Props) {
const { token } = props;
const [loading, setLoading] = useState(false)
const userAPI = useUserAPI();
const teamAPI = useTeamAPI();
const classes = useStyles();
const useAuth = useAuth0();
const hasFetched = useRef(false);
const [global, setGlobal] = useState<undefined | GlobalState>(undefined)
const snackbar = useSnackbar()
const isMobile = useMobile()
const user_id = or(useAuth.user?.sub, "")
const crispInitialized = useRef(false);
Crisp.configure(import.meta.env.VITE_CRISP_WEBSITE_ID);
const user_id = (() => {
try {
const payload = JSON.parse(atob(token.split('.')[1]))
return or(payload.sub, '')
} catch {
return ''
}
})()
const loadUser = useCallback(() => {
if (!userAPI.getUserWithTeam) return;
if (hasFetched.current) return;
setLoading(true)
userAPI.getUserWithTeam(user_id).then(resp => {
const loadedUser = resp.data.user ? User.create(resp.data.user) : User.create();
const loadedTeam = resp.data.team ? Team.create(resp.data.team) : Team.create();
const currentSlug = getWhitelabel().slug;
if (!loadedUser.empty() && loadedUser.settings.whitelabel !== currentSlug) {
loadedUser.settings.whitelabel = currentSlug;
userAPI.updateUser(loadedUser.id(), loadedUser.protobuf()).catch(() => {});
}
if (!loadedTeam.empty() && loadedTeam.settings.whitelabel === "") {
loadedTeam.settings.whitelabel = currentSlug;
teamAPI.updateTeam(loadedTeam.key(), loadedTeam.settings).catch(() => {});
}
setGlobal({
user: resp.data.user ? User.create(resp.data.user) : User.create(),
team: resp.data.team ? Team.create(resp.data.team) : Team.create(),
user: loadedUser,
team: loadedTeam,
as: resp.data.user?.settings?.useTeam === true ? resp.data.user.settings.defaultTeam : "",
showErrors: false,
userTeamPermissions: [],
@ -114,15 +131,14 @@ export default function UserWrapper(props: Props) {
}, [setGlobal])
useEffect(() => {
if (global?.user) {
initCrisp({
websiteId: import.meta.env.VITE_CRISP_WEBSITE_ID,
email: global.user.settings.email,
nickname: global.user.settings.name || global.user.settings.email,
phone: global.user.settings.phoneNumber,
tokenId: global.user.id(),
});
}
if (!global?.user || !isCrispEnabled()) return;
initCrisp({
websiteId: import.meta.env.VITE_CRISP_WEBSITE_ID,
email: global.user.settings.email,
nickname: global.user.settings.name || global.user.settings.email,
phone: global.user.settings.phoneNumber,
tokenId: global.user.id(),
});
}, [global]);
// useEffect(() => {
@ -150,6 +166,7 @@ export default function UserWrapper(props: Props) {
// }, [isMobile]);
useEffect(() => {
if (!isCrispEnabled()) return;
let style = document.getElementById("crisp-offset-override");
if (!style) {
style = document.createElement("style");

View file

@ -1,5 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { StyledEngineProvider } from '@mui/material/styles'
import App from './App'
// I don't consider providing a disabled button to a Tooltip an error.
@ -20,6 +21,8 @@ if ('serviceWorker' in navigator) {
createRoot(document.getElementById('root')!).render(
// <StrictMode>
<App />
<StyledEngineProvider injectFirst>
<App />
</StyledEngineProvider>
// </StrictMode>,
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View file

@ -0,0 +1,140 @@
import Circle, { CircleGeometry } from "3dModels/Shapes/3D/Circle"
import Cone, { ConeGeometry } from "3dModels/Shapes/3D/Cone"
import Cylinder, { CylinderGeometry } from "3dModels/Shapes/3D/Cylinder"
import React from "react"
import { Euler, Vector3 } from "three"
interface Props {
radialSegments: number
binBodyColour: string
binMetalness: number
binRoughness: number
binOpacity: number
diameter: number
sidewallHeight: number
roofHeight: number
hopperHeight?: number
wireframe?: boolean
renderOrder?: number
}
export default function BinShell(props: Props) {
const { radialSegments, binBodyColour, binMetalness, binRoughness, binOpacity, diameter, sidewallHeight, roofHeight, hopperHeight, wireframe, renderOrder } = props
const cylinderGeometry = React.useMemo(() => ({
radiusTop: diameter / 2,
radiusBottom: diameter / 2,
height: sidewallHeight,
radialSegments,
heightSegments: 2,
openEnded: true,
} as CylinderGeometry), [diameter, sidewallHeight, radialSegments]);
const roofGeometry = React.useMemo(() => ({
height: roofHeight,
radius: diameter /2,
radialSegments: radialSegments,
openEnded: true
} as ConeGeometry), [diameter, roofHeight, radialSegments])
const hopperGeometry = React.useMemo(() => ({
height: hopperHeight,
radius: diameter /2,
radialSegments: radialSegments,
openEnded: true
} as ConeGeometry),[hopperHeight, diameter, radialSegments])
const flatBottomGeometry = React.useMemo(() => ({
radius: diameter/2,
radialSegments: radialSegments
} as CircleGeometry),[diameter, radialSegments])
const roofPosition = React.useMemo(
() => new Vector3(0, sidewallHeight/2 + roofHeight/2, 0),
[sidewallHeight, roofHeight]
);
const bottomPosition = React.useMemo(
() => {
if(hopperHeight !== undefined && hopperHeight > 0){
//this returns the position for the center of the cone for the hopper
return new Vector3(0, (sidewallHeight/2 + hopperHeight/2)*-1, 0)
} else {
//this returns the position for the circle for the flat bottom
return new Vector3(0, -sidewallHeight/2, 0)
}
},
[sidewallHeight, hopperHeight]
)
const hopperRotation = React.useMemo(
() => new Euler(Math.PI, 0, 0),
[]
);
const flatBottomRotation = React.useMemo(
() => new Euler(-Math.PI / 2, 0, 0),
[]
);
return (
<React.Fragment>
{/* bin roof - cone */}
<Cone
geometry={roofGeometry}
colour={binBodyColour}
wireframe= {wireframe}
metalness={binMetalness}
position={roofPosition}
roughness={binRoughness}
side={2}
opacity={binOpacity}
depthWrite={false}
depthTest={false}
renderOrder={renderOrder}/>
{/* bin sidewall - cylinder (open ended for the roof and bottom)*/}
<Cylinder
geometry={cylinderGeometry}
colour={binBodyColour}
wireframe= {wireframe}
metalness={binMetalness}
roughness={binRoughness}
opacity={binOpacity}
depthWrite={false}
depthTest={false}
renderOrder={renderOrder}/>
{/* bin bottom - cone -OR- circle depending on the bins shape*/}
{hopperHeight !== undefined && hopperHeight > 0 ?
<Cone
geometry={hopperGeometry}
colour={binBodyColour}
wireframe= {wireframe}
metalness={binMetalness}
position={bottomPosition}
rotation={hopperRotation}
roughness={binRoughness}
opacity={binOpacity}
side={2}
depthWrite={false}
depthTest={false}
renderOrder={renderOrder}
/>
:
<Circle
geometry={flatBottomGeometry}
colour={binBodyColour}
wireframe= {wireframe}
metalness={binMetalness}
position={bottomPosition}
rotation={flatBottomRotation}
roughness={binRoughness}
opacity={binOpacity}
side={2}
depthWrite={false}
depthTest={false}
renderOrder={renderOrder}
/>
}
</React.Fragment>
)
}

View file

@ -0,0 +1,212 @@
import { RadiansToDegrees } from "common/TrigFunctions";
import { Bin } from "models";
import { GrainCable } from "models/GrainCable";
import { pond } from "protobuf-ts/pond";
import { Vector3 } from "three";
import { avg } from "utils";
export interface CableData {
cableIndex: number
radius: number
topPosition: Vector3
bottomPosition: Vector3
grainCable: pond.GrainCable
radialSegments?: number
heightSegments?: number
}
export interface CableOrbit {
orbit: number;
radius: number; // in cm
expectedCount: number;
assignedCount?: number;
}
/**
* Effective bin dimensions used for cable placement.
* Passed in from Bin3dView so that default fallback values are applied
* consistently cables will always match the rendered shell even when
* the bin has no advanced dimensions configured.
*/
export interface BinDims {
diameter: number
sidewallHeight: number
roofHeight: number
hopperHeight: number
}
function getLayoutFromDiameter(diameterCm: number): CableOrbit[] {
const diameterFt = diameterCm / 30.48;
let summaries: { Orbit: number; DistanceFromCenter: number; NumberOfCables: number }[] = [];
if (diameterFt < 24) {
summaries.push({ Orbit: 0, DistanceFromCenter: 2, NumberOfCables: 1 });
} else if (diameterFt < 36) {
summaries.push({ Orbit: 1, DistanceFromCenter: 7, NumberOfCables: 3 });
} else if (diameterFt < 42) {
summaries.push({ Orbit: 0, DistanceFromCenter: 2, NumberOfCables: 1 });
summaries.push({ Orbit: 1, DistanceFromCenter: 9, NumberOfCables: 3 });
} else if (diameterFt < 48) {
summaries.push({ Orbit: 0, DistanceFromCenter: 2, NumberOfCables: 1 });
summaries.push({ Orbit: 1, DistanceFromCenter: 14, NumberOfCables: 4 });
} else if (diameterFt < 54) {
summaries.push({ Orbit: 0, DistanceFromCenter: 2, NumberOfCables: 1 });
summaries.push({ Orbit: 1, DistanceFromCenter: 16, NumberOfCables: 5 });
} else if (diameterFt < 60) {
summaries.push({ Orbit: 0, DistanceFromCenter: 2, NumberOfCables: 1 });
summaries.push({ Orbit: 1, DistanceFromCenter: 18, NumberOfCables: 6 });
} else if (diameterFt < 72) {
summaries.push({ Orbit: 0, DistanceFromCenter: 2, NumberOfCables: 1 });
summaries.push({ Orbit: 1, DistanceFromCenter: 20, NumberOfCables: 7 });
} else if (diameterFt < 78) {
summaries.push({ Orbit: 1, DistanceFromCenter: 10, NumberOfCables: 4 });
summaries.push({ Orbit: 2, DistanceFromCenter: 27, NumberOfCables: 9 });
} else if (diameterFt < 90) {
summaries.push({ Orbit: 1, DistanceFromCenter: 10, NumberOfCables: 4 });
summaries.push({ Orbit: 2, DistanceFromCenter: 29, NumberOfCables: 10 });
} else if (diameterFt < 105) {
summaries.push({ Orbit: 0, DistanceFromCenter: 2, NumberOfCables: 1 });
summaries.push({ Orbit: 1, DistanceFromCenter: 18, NumberOfCables: 6 });
summaries.push({ Orbit: 2, DistanceFromCenter: 36, NumberOfCables: 12 });
} else {
summaries.push({ Orbit: 1, DistanceFromCenter: 9, NumberOfCables: 3 });
summaries.push({ Orbit: 2, DistanceFromCenter: 26.5, NumberOfCables: 9 });
summaries.push({ Orbit: 3, DistanceFromCenter: 44, NumberOfCables: 14 });
}
return summaries.map(s => ({
orbit: s.Orbit,
radius: s.DistanceFromCenter * 30.48, // ft → cm
expectedCount: s.NumberOfCables
}));
};
function distributeCables(cableCount: number, layout: CableOrbit[]) {
const totalExpected = layout.reduce((sum, o) => sum + o.expectedCount, 0);
let assigned = layout.map(o => ({
...o,
assignedCount: Math.round((o.expectedCount / totalExpected) * cableCount)
}));
// normalize rounding
let currentTotal = assigned.reduce((sum, o) => sum + (o.assignedCount ?? 0), 0);
while (currentTotal < cableCount) {
assigned[assigned.length - 1].assignedCount!++;
currentTotal++;
}
while (currentTotal > cableCount) {
assigned[assigned.length - 1].assignedCount!--;
currentTotal--;
}
return assigned;
};
function getTopY(radiusFromCenter: number, dims: BinDims) {
const binRadius = dims.diameter / 2;
const distanceFromEdge = binRadius - radiusFromCenter;
// Use the real roof angle when available; when dimensions aren't configured
// the angle will be 0 which gives a flat cable top — correct for a default shape.
const angleRad = Math.atan((dims.roofHeight) / (binRadius));
const roofRise = Math.tan(angleRad) * distanceFromEdge;
return dims.sidewallHeight / 2 + roofRise;
};
function getBottomY(radiusFromCenter: number, dims: BinDims) {
const binRadius = dims.diameter / 2;
const distanceFromCenter = binRadius - radiusFromCenter;
const angleRad = Math.atan((dims.hopperHeight) / (binRadius))
const hopperDrop = Math.tan(angleRad) * distanceFromCenter;
const clearance = 60; // cm
return -dims.sidewallHeight / 2 - hopperDrop + clearance;
};
export function BuildCableData(bin: Bin, dims: BinDims): CableData[] {
const cables = [...(bin.status.grainCables ?? [])];
if (cables.length === 0) return [];
// optional: sort cables (better visual consistency later)
//cables.sort((a, b) => b.celcius.length - a.celcius.length);
cables.sort((a, b) => {
//if the cables have the same number of nodes
if (b.celcius.length === a.celcius.length) {
//sort by temp only last and any type that has humidity first
if ((avg(a.relativeHumidity) === 0) && (avg(b.relativeHumidity) !== 0)) {
return 1;
} else if ((avg(b.relativeHumidity) === 0) && (avg(a.relativeHumidity) !== 0)) {
return -1;
}
else {
return a.key > b.key ? 1 : -1;
}
}
//otherwise sort by the longest cable first
return b.celcius.length - a.celcius.length;
});
const layout = getLayoutFromDiameter(dims.diameter);
const distributed = distributeCables(cables.length, layout);
const cableRadius = 3;
const result: CableData[] = [];
let cableIndex = 0;
distributed.forEach(orbit => {
const count = orbit.assignedCount ?? 0;
const topY = getTopY(orbit.radius, dims); //this is if we want the cables to start at the roof
//const topY = dims.sidewallHeight/2 //this will start the cables at the top of the sidewall
const bottomY = getBottomY(orbit.radius, dims);
if (orbit.orbit === 0 && count > 0) {
const cable = cables[cableIndex];
if (!cable) return;
result.push({
cableIndex: cableIndex,
radius: cableRadius,
topPosition: new Vector3(0, topY, 0),
bottomPosition: new Vector3(0, bottomY, 0),
grainCable: cable
});
cableIndex++;
return;
}
for (let i = 0; i < count; i++) {
const angle = (i / count) * Math.PI * 2;
const x = Math.cos(angle) * orbit.radius;
const z = Math.sin(angle) * orbit.radius;
const cable = cables[cableIndex];
result.push({
cableIndex: cableIndex,
radius: cableRadius,
topPosition: new Vector3(x, topY, z),
bottomPosition: new Vector3(x, bottomY, z),
grainCable: cable
});
cableIndex++;
}
});
return result;
};

View file

@ -0,0 +1,72 @@
import { Vector3 } from "three"
import { CableData } from "./BuildCableData"
export interface NodeData {
cableIndex: number
nodeIndex: number
radius: number
position: Vector3
celcius: number
humidity?: number
moisture?: number
topNode?: boolean
excluded?: boolean
inGrain?: boolean
/**
* The vertical spacing between nodes on this cable (cm).
* Used by GrainCableFill to offset the grain surface half a spacing above the top node,
* matching the 2D bin view behaviour.
*/
nodeSpacing: number
}
export function BuildNodeData(cables: CableData[]): NodeData[] {
const nodeRadius = 10
let nodeData: NodeData[] = []
cables.forEach((cable, cableIndex) => {
const grainCable = cable.grainCable
const nodeCount = grainCable.celcius.length;
if (nodeCount === 0) return;
const bottomY = cable.bottomPosition.y;
const topY = cable.topPosition.y;
const height = topY - bottomY;
const spacing = height / nodeCount;
grainCable.celcius.forEach((celcius, i) => {
const nodeY = bottomY + (i * spacing);
const humidity = grainCable.relativeHumidity[i] || undefined;
const moisture = grainCable.moisture[i] || undefined;
let t = celcius
// for testing
// if(i===0){
// t = 40
// }
// if (i===5){
// t = 0
// }
nodeData.push({
cableIndex: cableIndex,
nodeIndex: i,
radius: nodeRadius,
position: new Vector3(cable.bottomPosition.x, nodeY, cable.bottomPosition.z),
celcius: t,
humidity: humidity,
moisture: moisture,
topNode: grainCable.topNode === i + 1,
excluded: grainCable.excludedNodes?.includes(i) ?? false,
inGrain: i + 1 <= grainCable.topNode || !grainCable.topNode,
nodeSpacing: spacing,
})
})
})
return nodeData
}

View file

@ -0,0 +1,216 @@
import React from "react";
import OrbitCameraControls from "3dModels/CameraControls/OrbitCameraControls";
import CameraOverlay from "3dModels/CameraControls/CameraOverlay";
import { Canvas } from "@react-three/fiber";
import { Bin } from "models";
import BinShell from "../BinParts/BinShell";
import GrainFillFlat from "../Systems/Inventory/GrainFillFlat";
import BinCables from "../Systems/Cables/BinCables";
// import { Vector3 } from "three";
import { useMemo } from "react";
import { BuildCableData, CableData } from "../Data/BuildCableData";
import { BuildNodeData, NodeData } from "../Data/BuildNodeData";
import { pond } from "protobuf-ts/pond";
import GrainCableFill from "../Systems/Inventory/GrainCableFill";
import TempHeatMap from "../Systems/Heatmap/TempHeatMap";
import NodePointCloud from "../Systems/Heatmap/NodePointCloud";
// import { Box } from "@mui/material";
import MoistureHeatMap from "../Systems/Heatmap/MoistureHeatmap";
import { grainYBoundsFromFillPercent } from "../utils/grainFillBounds";
// import { RadiansToDegrees } from "common/TrigFunctions";
// import { GrainCable } from "models/GrainCable"
// Fallback dimensions used when a bin has no advanced dimensions configured.
// Chosen to resemble a typical mid-size grain bin.
const DEFAULT_ROOF_HEIGHT_CM = 200; // 2 m
const DEFAULT_FLAT_HEIGHT_CM = 0; // flat bottom
const DEFAULT_HOPPER_HEIGHT_CM = 280; // hopper bottom
interface Props {
bin: Bin
scale: number
/**
* cables can come from the bins status when not passed in, however data may be out of synce from when the cables read to when the bins status updates
* they may need to be passed in but for the moment we wont worry about it due to the last active on the bin page can be used to explain being out of sync
* if it becomes an issue I can make the changes necessary to pass the cable components in
*/
// cables?: GrainCable[]
/**
* the percentage of the bin to be filled, expects a number between 0 and 1
*/
fillPercent?: number
nodeClick?: (node: NodeData, cable: CableData) => void
/**
* When true, renders the basic fill and suppresses the heatmaps.
* Heatmaps already simulate the grain level so showing both is redundant.
*/
showGrain?: boolean
showTempHeatmap?: boolean
showMoistureHeatmap?: boolean
showHotspots?: boolean
/**
* When true renders a reset camera button overlaid on the 3D view.
*/
showResetButton?: boolean
showTemp?: boolean
showMoisture?: boolean
xOffset?: number
width?: number
height?: number
}
export default function Bin3dView(props: Props) {
const { bin, scale = 100, fillPercent, nodeClick, showTempHeatmap, showGrain, showHotspots, showResetButton, showMoistureHeatmap, showTemp, showMoisture, xOffset, width = 1, height = 1 } = props
// Resolve effective dimensions, falling back to defaults when advanced
// dimensions have not been configured (bin methods return 0 in that case).
const dims = useMemo(() => ({
diameter: bin.diameter(),
sidewallHeight: bin.sidewallHeight() || bin.diameter() + 50, //calculated using the diameter so 'default' bins always have the same shape
roofHeight: bin.roofHeight() || DEFAULT_ROOF_HEIGHT_CM,
hopperHeight: bin.hopperHeight() || (bin.binShape() === pond.BinShape.BIN_SHAPE_HOPPER_BOTTOM ? DEFAULT_HOPPER_HEIGHT_CM : DEFAULT_FLAT_HEIGHT_CM),
}), [bin]);
// Compute the initial camera radius so the full bin fits in frame.
// Uses the perspective camera FOV (default 75°) with a padding factor.
// Both the diameter and total height are considered so neither is clipped.
const initialCameraRadius = useMemo(() => {
const totalHeight = (dims.sidewallHeight + dims.roofHeight + dims.hopperHeight) / scale;
const diameter = dims.diameter / scale;
const largestDim = Math.max(totalHeight, diameter);
const fovRad = (75 * Math.PI) / 180;
const aspect = width/height
const aspectPadding = aspect < 1 ? 1.4 : 1.0 // extra room on mobile
// const padding = 1.3; // 30% breathing room
const padding = (1.1 + (largestDim/100) * 4.5) * aspectPadding
return (largestDim / (2 * Math.tan(fovRad / 2))) * padding;
}, [dims, scale]);
const cableData = useMemo(() => BuildCableData(bin, dims), [bin, dims]);
const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]);
const isCableInventory =
bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_AUTOMATIC ||
bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_HYBRID_CABLE;
// For cable inventory, TempHeatMap derives the wavy surface directly from
// the top nodes in nodeData — no scalar needed.
//
// Flat fill: Y bounds from the same volume math as GrainFillFlat.
const flatGrainBounds = useMemo(() => {
if (isCableInventory || fillPercent === undefined) return undefined;
return grainYBoundsFromFillPercent(
dims.diameter,
dims.sidewallHeight,
dims.hopperHeight,
dims.roofHeight,
fillPercent
);
}, [isCableInventory, fillPercent, dims]);
// Internal ref — wired to OrbitCameraControls and called by CameraOverlay
const resetCameraFn = React.useRef<(() => void) | null>(null);
const grainInventory = () => {
const grainColour = "#fff300";
//note that adjusting the opacity is how to adjust the brightness, less makes it dimmer
if (isCableInventory) {
return (
<GrainCableFill
diameter={dims.diameter}
colour={grainColour}
nodes={nodeData}
sidewallHeight={dims.sidewallHeight}
fallbackFillPercent={fillPercent}
hopperHeight={dims.hopperHeight}
// grainOpacity={0.7}
/>
)
} else if (fillPercent) {
return (
<GrainFillFlat
diameter={dims.diameter}
colour={grainColour}
sidewallHeight={dims.sidewallHeight}
hopperHeight={dims.hopperHeight}
fillPercent={fillPercent}
// grainOpacity={0.7}
/>
)
}
}
return (
<Canvas style={{position: "absolute", inset: 0}}>
<OrbitCameraControls
clampVerticalRotation
initialRadius={initialCameraRadius}
maxRadius={initialCameraRadius * 2}
onReset={fn => { resetCameraFn.current = fn; }}
viewOffset={xOffset}
/>
<group scale={[1 / scale, 1 / scale, 1 / scale]}>
<BinShell
binBodyColour="#fff"
radialSegments={20}
binMetalness={0.7}
binRoughness={0.3}
binOpacity={0.2}
diameter={dims.diameter}
sidewallHeight={dims.sidewallHeight}
roofHeight={dims.roofHeight}
hopperHeight={dims.hopperHeight}
renderOrder={4}
/>
<BinCables
displayTemp={showTemp}
displayMoisture={showMoisture}
cableData={cableData}
nodeData={nodeData}
bin={bin}
onNodeClick={(node, cable) => {
if (nodeClick) nodeClick(node, cable)
}}
renderOrder={5}
/>
{showHotspots && <NodePointCloud bin={bin} nodes={nodeData} />}
{/* note that the heatmaps internally use render order 1,2, and 3 for the three separate coloured meshes */}
{showGrain ? grainInventory() :
showTempHeatmap && nodeData.length > 0
? <TempHeatMap
binDimensions={dims}
targetTemp={bin.targetTemp()}
nodes={nodeData}
flatMaxY={flatGrainBounds?.maxY}
flatMinY={flatGrainBounds?.minY}
/>
: showMoistureHeatmap && nodeData.length > 0
? <MoistureHeatMap
binDimensions={dims}
targetMoisture={bin.targetMoisture()}
nodes={nodeData}
flatMaxY={flatGrainBounds?.maxY}
flatMinY={flatGrainBounds?.minY}
/>
: grainInventory()
}
</group>
<ambientLight intensity={0.2} color={"white"} />
<directionalLight intensity={0.2} color={"white"} position={[0, 0, 5]} />
<directionalLight intensity={0.2} color={"white"} position={[0, 0, -5]} />
<directionalLight intensity={0.2} color={"white"} position={[5, 0, 0]} />
<directionalLight intensity={0.2} color={"white"} position={[-5, 0, 0]} />
{/* Overlay — must be inside <Canvas> so it shares the same stacking context */}
{showResetButton && (
<CameraOverlay onReset={() => resetCameraFn.current?.()} />
)}
</Canvas>
)
}

View file

@ -0,0 +1,44 @@
import GrainCable from "./GrainCable";
import { Bin } from "models";
import React from "react";
import { CableData } from "../../Data/BuildCableData";
import { NodeData } from "../../Data/BuildNodeData";
interface Props {
cableData: CableData[],
nodeData: NodeData[],
bin: Bin,
renderOrder?: number,
displayTemp?: boolean,
displayMoisture?: boolean,
onNodeClick?: (node: NodeData, cable: CableData) => void
}
export default function BinCables(props: Props){
const {bin, cableData, nodeData, onNodeClick, renderOrder, displayTemp, displayMoisture} = props
return (
<React.Fragment>
{cableData.map((cable, i) => {
const cableNodes = nodeData.filter(n => n.cableIndex === cable.cableIndex);
return (
<GrainCable
key={"cable " + i}
cable={cable}
nodes={cableNodes}
bin={bin}
displayTemp={displayTemp}
displayMoisture={displayMoisture}
onNodeClick={(node) => {
if(onNodeClick){
onNodeClick(node, cable)
}
}}
renderOrder={renderOrder}
/>
)}
)}
</React.Fragment>
)
}

View file

@ -0,0 +1,187 @@
import Sphere, { SphereGeometry } from "3dModels/Shapes/3D/Sphere"
import { useFrame, useThree } from "@react-three/fiber"
import { Text } from "@react-three/drei"
import React, { useMemo } from "react"
import { Euler, Group, Vector3 } from "three"
import { green, grey, orange, red } from "@mui/material/colors";
import { useGlobalState } from "providers"
import { pond } from "protobuf-ts/pond"
import Ring, { RingGeometry } from "3dModels/Shapes/3D/Ring"
import { NodeData } from "../../Data/BuildNodeData"
interface Props {
node: NodeData
upperThreshold: number,
targetMoisture: number,
renderOrder?: number,
displayTemp?: boolean,
displayMoisture?: boolean,
onNodeClick?: (node: NodeData) => void
}
export default function CableNode(props: Props) {
const { node, upperThreshold, targetMoisture, onNodeClick, renderOrder, displayTemp, displayMoisture } = props
const { camera } = useThree();
const [{ user }] = useGlobalState();
// const [showLabel, setShowLabel] = useState(false);
const ROW_HEIGHT = 35;
const PADDING = 5;
const LABEL_WIDTH = 200;
const FONT_SIZE = 25;
const flagRef = React.useRef<Group>(null);
useFrame(() => {
if (!flagRef.current || !showLabel) return;
// Get camera's right direction in world space
const cameraRight = new Vector3();
camera.getWorldDirection(cameraRight); // forward
const up = new Vector3(0, 1, 0);
cameraRight.crossVectors(cameraRight, up).normalize(); // right = forward × up
const pos = node.position.clone();
pos.addScaledVector(cameraRight, LABEL_WIDTH / 2); // offset right in screen space
pos.addScaledVector(camera.position.clone().sub(node.position).normalize(), 1); // nudge toward camera
flagRef.current.position.copy(pos);
flagRef.current.quaternion.copy(camera.quaternion);
});
const tempDisplay = useMemo(() => {
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
return ((node.celcius * 9 / 5) + 32).toFixed(2) + "°F"
}
return node.celcius.toFixed(2) + "°C"
}, [node.celcius, user]);
const rows = useMemo(() => {
const anyToggleOn = displayTemp || displayMoisture;
if (node.excluded) {
if (!anyToggleOn) return [];
return [{ text: "Excluded", color: grey[700] }];
}
const r: { text: string; color: string }[] = [];
// TEMP
if (displayTemp && node.celcius !== 0) {
r.push({
text: tempDisplay,
// color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour()
//will be green when below threshold, orange when it goeas above but by less than 5 degrees, and red if more than 5 over
color: node.celcius > upperThreshold ? node.celcius > upperThreshold + 5 ? red[700] : orange[800]: green[800]
});
}
// MOISTURE
if (displayMoisture) {
if (node.moisture && node.moisture > 0) {
r.push({
text: `${node.moisture.toFixed(2)}% EMC`,
// color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour()
color: node.moisture > targetMoisture ? node.moisture > targetMoisture + 0.5 ? red[700] : orange[800]: green[800]
});
}
//apparently we dont want to display humidity at all
// else if (node.humidity && node.humidity > 0) {
// r.push({
// text: `${node.humidity.toFixed(2)}%`,
// // color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour()
// color: "black"
// });
// }
}
const hasData = r.length > 0;
// Only show top node if there's actual data
if (node.topNode && hasData) {
r.unshift({ text: "Top Node", color: grey[700] });
}
return r;
}, [node, tempDisplay, displayTemp, displayMoisture]);
const geometry = React.useMemo(() => ({
radius: node.radius,
} as SphereGeometry), [node]);
const topNodeGeo = React.useMemo(() => ({
radius: node.radius,
thickness: 5,
} as RingGeometry), [node]);
const topNodePosition = React.useMemo(
() => new Vector3(node.position.x, node.position.y + 25, node.position.z),
[node]
);
const topNodeRotation = React.useMemo(() => new Euler(-Math.PI / 2, 0, 0), []);
const labelHeight = rows.length * ROW_HEIGHT + PADDING;
const getRowY = (index: number) => {
const totalHeight = rows.length * ROW_HEIGHT;
return totalHeight / 2 - (index * ROW_HEIGHT) - ROW_HEIGHT / 2;
};
const nodeColour = () => {
if (node.excluded) return "grey";
return "white";
};
const handleClick = (e: any) => {
e.stopPropagation();
onNodeClick?.(node);
};
const showLabel = rows.length > 0;
return (
<React.Fragment>
<React.Fragment>
<Sphere
geometry={geometry}
position={node.position}
colour={nodeColour()}
onClick={handleClick}
renderOrder={renderOrder}
depthTest={false}
depthWrite={false}
opacity={0.99}
/>
{node.topNode && (
<Ring geometry={topNodeGeo} position={topNodePosition} rotation={topNodeRotation} renderOrder={renderOrder} opacity={0.99} depthTest={false} depthWrite={false}/>
)}
</React.Fragment>
{node.inGrain && showLabel &&
<group ref={flagRef} renderOrder={999} onClick={handleClick}>
<mesh position={[0, 0, -1]}>
<planeGeometry args={[LABEL_WIDTH, labelHeight]} />
<meshBasicMaterial
color="white"
transparent
opacity={0.9}
depthWrite={false}
depthTest={false}
/>
</mesh>
{rows.map((row, i) => (
<Text
key={i}
position={[0, getRowY(i), 0]}
fontSize={FONT_SIZE}
fontWeight={650}
color={row.color}
anchorX="center"
anchorY="middle"
>
{row.text}
</Text>
))}
</group>
}
</React.Fragment>
)
}

View file

@ -0,0 +1,50 @@
import Cylinder, { CylinderGeometry } from "3dModels/Shapes/3D/Cylinder"
import React from "react"
import { Vector3 } from "three"
import CableNode from "./CableNode"
import { Bin } from "models"
import { CableData } from "../../Data/BuildCableData"
import { NodeData } from "../../Data/BuildNodeData"
interface Props {
cable: CableData,
nodes: NodeData[],
bin: Bin,
renderOrder?: number,
displayTemp?: boolean,
displayMoisture?: boolean,
onNodeClick?: (node: NodeData) => void
}
export default function GrainCable(props: Props) {
const {cable, nodes, bin, onNodeClick, renderOrder, displayTemp, displayMoisture} = props
const calculateHeight = () => {
return cable.topPosition.distanceTo(cable.bottomPosition)
}
const calculateCenter = () => {
return cable.bottomPosition.clone()
.add(
cable.topPosition.clone()
.sub(cable.bottomPosition)
.multiplyScalar(0.5)
);
};
const geometry = React.useMemo(() => ({
radiusTop: cable.radius,
radiusBottom: cable.radius,
height: calculateHeight(),
radialSegments: cable.radialSegments ?? 10,
heightSegments: cable.heightSegments ?? 2
} as CylinderGeometry), [cable]);
return (
<React.Fragment>
<Cylinder geometry={geometry} position={calculateCenter()} opacity={0.99} renderOrder={renderOrder} depthWrite={false} depthTest={false}/>
{nodes.map((node, i) => (
<CableNode displayTemp={displayTemp} displayMoisture={displayMoisture} onNodeClick={onNodeClick} key={"node " + i} renderOrder={renderOrder} node={node} targetMoisture={bin.targetMoisture()} upperThreshold={bin.targetTemp()}/>
))}
</React.Fragment>
)
}

View file

@ -0,0 +1,466 @@
import { useMemo } from "react";
import * as THREE from "three";
import { Bin } from "models";
import { NodeData } from "../../Data/BuildNodeData";
import React from "react";
import { BinDims } from "bin/3dView/Data/BuildCableData";
interface Props {
binDimensions: BinDims
targetMoisture: number
nodes: NodeData[];
/**
* For flat fill percent inventory a scalar Y ceiling for the heatmap top.
* Used when there are no cable top nodes to drive a surface.
*/
flatMaxY?: number;
/** For flat fill percent — Y floor of filled grain (hopper tip or cylinder base). */
flatMinY?: number;
}
const RADIAL_RINGS = 20;
const THETA_SEGMENTS = 40;
const HEIGHT_STEPS = 28;
const YELLOW_DELTA = 2;
const RED_DELTA = 4;
const IDW_POWER = 4;
const RED_OPACITY = 0.3;
const GREEN_OPACITY = 0.3;
const YELLOW_OPACITY = 0.8;
const ANGLE_JITTER = 0.2;
const RADIAL_JITTER = 0.05;
const LAYER_TWIST = 0.22;
// IDW power for the horizontal surface interpolation —
// matches GrainCableFill's default of 2
const SURFACE_IDW_POWER = 2;
function moistureToHeat(moisture: number, target: number): number {
if (moisture <= target) return 0;
const delta = moisture - target;
if (delta >= RED_DELTA) return 2;
if (delta >= YELLOW_DELTA) return 1 + (delta - YELLOW_DELTA) / (RED_DELTA - YELLOW_DELTA);
return delta / YELLOW_DELTA;
}
function heatToRGB(heat: number): number[] {
const GREEN = [0, 0.5, 0.02];
const YELLOW = [0.7, 0.86, 0.0];
const RED = [0.8, 0.0, 0.01];
if (heat <= 0) return GREEN;
if (heat <= 1) {
const t = Math.pow(heat, 0.75);
return [
GREEN[0] + (YELLOW[0] - GREEN[0]) * t,
GREEN[1] + (YELLOW[1] - GREEN[1]) * t,
GREEN[2] + (YELLOW[2] - GREEN[2]) * t,
];
}
const t = Math.pow(heat - 1, 0.75);
return [
YELLOW[0] + (RED[0] - YELLOW[0]) * t,
YELLOW[1] + (RED[1] - YELLOW[1]) * t,
YELLOW[2] + (RED[2] - YELLOW[2]) * t,
];
}
interface MoistureAnchor {
x: number; y: number; z: number;
moisture: number;
}
interface SurfaceAnchor {
x: number; z: number; y: number;
}
// 3D IDW for moisture interpolation
function idwMoisture(
px: number, py: number, pz: number,
anchors: MoistureAnchor[],
power: number
): number {
let totalWeight = 0;
let weightedSum = 0;
for (const a of anchors) {
const dx = px - a.x;
const dy = py - a.y;
const dz = pz - a.z;
const distSq = dx * dx + dy * dy + dz * dz;
if (distSq < 0.001) return a.moisture;
const weight = 1 / Math.pow(distSq, power / 2);
totalWeight += weight;
weightedSum += a.moisture * weight;
}
return totalWeight === 0 ? 0 : weightedSum / totalWeight;
}
// 2D IDW for surface height interpolation — matches GrainCableFill exactly
function idwSurfaceY(
x: number, z: number,
anchors: SurfaceAnchor[],
power: number
): number {
let totalWeight = 0;
let weightedY = 0;
for (const a of anchors) {
const dx = x - a.x;
const dz = z - a.z;
const distSq = dx * dx + dz * dz;
if (distSq < 0.001) return a.y;
const weight = 1 / Math.pow(distSq, power / 2);
totalWeight += weight;
weightedY += a.y * weight;
}
return totalWeight === 0 ? 0 : weightedY / totalWeight;
}
function hashNoise(a: number, b: number, c: number) {
const x = Math.sin(a * 127.1 + b * 311.7 + c * 74.7) * 43758.5453;
return (x - Math.floor(x)) * 2 - 1;
}
export default function MoistureHeatMap({ binDimensions, targetMoisture, nodes, flatMaxY, flatMinY }: Props) {
const binRadius = binDimensions.diameter / 2;
const sidewallHeight = binDimensions.sidewallHeight;
const hopperHeight = binDimensions.hopperHeight ?? 0;
const roofHeight = binDimensions.roofHeight ?? 0;
const sidewallBaseY = -sidewallHeight / 2;
const sidewallTopY = sidewallHeight / 2;
const hopperTipY = sidewallBaseY - hopperHeight;
const roofTipY = sidewallTopY + roofHeight;
const maxRadiusAtY = (y: number) => {
if (y > sidewallTopY) {
if (roofHeight <= 0 || y >= roofTipY) return 0;
return binRadius * (1 - (y - sidewallTopY) / roofHeight);
}
if (y >= sidewallBaseY) return binRadius;
if (hopperHeight <= 0 || y <= hopperTipY) return 0;
return binRadius * ((y - hopperTipY) / hopperHeight);
};
// Moisture anchors — all in-grain nodes that have a moisture reading
const moistureAnchors = useMemo<MoistureAnchor[]>(
() =>
nodes
.filter(n => n.inGrain && !n.excluded && n.moisture !== undefined)
.map(n => ({
x: n.position.x,
y: n.position.y,
z: n.position.z,
moisture: n.moisture as number,
})),
[nodes]
);
// Surface anchors — top nodes only, Y offset by half spacing.
// Only used when flatMaxY is NOT provided (i.e. Automatic / Hybrid_Cable
// inventory control). For any other control type the caller passes flatMaxY
// and we must use a flat surface instead of the wavy cable-driven one.
const surfaceAnchors = useMemo<SurfaceAnchor[]>(() => {
if (flatMaxY !== undefined) {
// Non-cable inventory: force flat surface — ignore cable top nodes
return [];
}
return nodes
.filter(n => n.topNode && n.inGrain && !n.excluded)
.map(n => ({
x: n.position.x,
z: n.position.z,
y: n.position.y + n.nodeSpacing * 0.5,
}));
}, [nodes, flatMaxY]);
// Wall clamp Y — average of surface anchor heights.
// Prevents the surface from piling up at the bin wall where there are no cables.
// Matches GrainCableFill's wallY computation.
const wallY = useMemo(() => {
if (surfaceAnchors.length === 0) return flatMaxY ?? sidewallBaseY;
return surfaceAnchors.reduce((sum, a) => sum + a.y, 0) / surfaceAnchors.length;
}, [surfaceAnchors, flatMaxY, sidewallBaseY]);
// Lowest Y the grain surface or heatmap may occupy (hopper tip or cylinder floor).
const grainFloorY = hopperHeight > 0 ? hopperTipY : sidewallBaseY;
// For each (x, z) position, returns the Y ceiling of the grain surface.
// Uses cable surface IDW when top nodes exist, falls back to flatMaxY.
const getSurfaceY = (x: number, z: number): number => {
if (surfaceAnchors.length > 0) {
const raw = idwSurfaceY(x, z, surfaceAnchors, SURFACE_IDW_POWER);
return Math.max(grainFloorY, Math.min(roofTipY, raw));
}
return flatMaxY ?? sidewallBaseY;
};
// Global max Y — highest point of the surface (used for grid bottom calc)
const maxGrainY = useMemo(() => {
if (surfaceAnchors.length > 0) {
return Math.max(...surfaceAnchors.map(a => a.y), wallY);
}
return flatMaxY ?? sidewallBaseY;
}, [surfaceAnchors, wallY, flatMaxY, sidewallBaseY]);
// Bottom of filled grain. Flat fill uses volume bounds; cable fill rests on
// the hopper tip (matches GrainCableFill) rather than the lowest cable node.
const minGrainY = useMemo(() => {
if (flatMinY !== undefined) return flatMinY;
return grainFloorY;
}, [flatMinY, grainFloorY]);
const geometry = useMemo(() => {
if (!moistureAnchors.length) return null;
const pointsPerLayer = 1 + RADIAL_RINGS * THETA_SEGMENTS;
const totalVerts = HEIGHT_STEPS * pointsPerLayer + 1;
const positions = new Float32Array(totalVerts * 3);
const colors = new Float32Array(totalVerts * 3);
const heats = new Float32Array(totalVerts);
const grainBottomY = minGrainY;
const grainHeight = maxGrainY - grainBottomY;
if (grainHeight <= 0) return null;
// Pre-compute the (x, z) position for each (ring, seg) slot so we
// can look up the surface Y ceiling per column
const colX: number[] = new Array(RADIAL_RINGS * THETA_SEGMENTS);
const colZ: number[] = new Array(RADIAL_RINGS * THETA_SEGMENTS);
const colSurfaceY: number[] = new Array(RADIAL_RINGS * THETA_SEGMENTS);
// Use the outermost layer's radius for surface Y lookup (no jitter/twist)
// so the surface shape matches GrainCableFill cleanly
const topLayerAllowedRadius = maxRadiusAtY(maxGrainY) * 0.97;
for (let ring = 0; ring < RADIAL_RINGS; ring++) {
const u = (ring + 1) / RADIAL_RINGS;
const rFrac = 1 - Math.pow(1 - u, 2.2);
const r = rFrac * topLayerAllowedRadius;
for (let seg = 0; seg < THETA_SEGMENTS; seg++) {
const angle = (seg / THETA_SEGMENTS) * Math.PI * 2;
const x = Math.cos(angle) * r;
const z = Math.sin(angle) * r;
const ci = ring * THETA_SEGMENTS + seg;
colX[ci] = x;
colZ[ci] = z;
// Outermost ring uses wallY, inner rings use full IDW surface
colSurfaceY[ci] = ring === RADIAL_RINGS - 1
? wallY
: getSurfaceY(x, z);
}
}
// Center column surface Y
const centerSurfaceY = getSurfaceY(0, 0);
for (let hStep = 0; hStep < HEIGHT_STEPS; hStep++) {
const t = hStep / (HEIGHT_STEPS - 1);
const layerBase = hStep * pointsPerLayer;
const allowedRadius = maxRadiusAtY(grainBottomY + t * grainHeight) * 0.97;
// Center vertex — Y is lerped from bottom to its column surface ceiling
const centerY = grainBottomY + t * (centerSurfaceY - grainBottomY);
const centerMoisture = idwMoisture(0, centerY, 0, moistureAnchors, IDW_POWER);
const centerHeat = moistureToHeat(centerMoisture, targetMoisture);
const [cr, cg, cb] = heatToRGB(centerHeat);
positions[layerBase * 3] = 0;
positions[layerBase * 3 + 1] = centerY;
positions[layerBase * 3 + 2] = 0;
colors[layerBase * 3] = cr;
colors[layerBase * 3 + 1] = cg;
colors[layerBase * 3 + 2] = cb;
heats[layerBase] = centerHeat;
for (let ring = 0; ring < RADIAL_RINGS; ring++) {
const u = (ring + 1) / RADIAL_RINGS;
const rFrac = 1 - Math.pow(1 - u, 2.2);
for (let seg = 0; seg < THETA_SEGMENTS; seg++) {
const noiseA = hashNoise(hStep, ring, seg);
const noiseR = hashNoise(seg, hStep, ring + 11);
const baseRadius = rFrac * allowedRadius;
const jitterRadius = baseRadius + noiseR * RADIAL_JITTER * allowedRadius;
const r = Math.max(0, Math.min(jitterRadius, allowedRadius));
const layerPhase = t * LAYER_TWIST;
const angle =
(seg / THETA_SEGMENTS) * Math.PI * 2 +
layerPhase +
noiseA * ANGLE_JITTER;
const x = Math.cos(angle) * r;
const z = Math.sin(angle) * r;
// Per-column surface Y ceiling — this is what gives the wavy top
const ci = ring * THETA_SEGMENTS + seg;
const surfaceY = colSurfaceY[ci];
// Lerp this vertex Y from grainBottomY up to its column surface ceiling
const y = grainBottomY + t * (surfaceY - grainBottomY);
const moisture = idwMoisture(x, y, z, moistureAnchors, IDW_POWER);
const heat = moistureToHeat(moisture, targetMoisture);
const [vr, vg, vb] = heatToRGB(heat);
const vi = layerBase + 1 + ring * THETA_SEGMENTS + seg;
heats[vi] = heat;
positions[vi * 3] = x;
positions[vi * 3 + 1] = y;
positions[vi * 3 + 2] = z;
colors[vi * 3] = vr;
colors[vi * 3 + 1] = vg;
colors[vi * 3 + 2] = vb;
}
}
}
const idx = (hStep: number, ring: number, seg: number) => {
if (ring < 0) return hStep * pointsPerLayer;
const s = ((seg % THETA_SEGMENTS) + THETA_SEGMENTS) % THETA_SEGMENTS;
return hStep * pointsPerLayer + 1 + ring * THETA_SEGMENTS + s;
};
const green: number[] = [];
const yellow: number[] = [];
const red: number[] = [];
function pushTri(a: number, b: number, c: number) {
const avg = (heats[a] + heats[b] + heats[c]) / 3;
if (avg < 1) green.push(a, b, c);
else if (avg < 2) yellow.push(a, b, c);
else red.push(a, b, c);
}
for (let h = 0; h < HEIGHT_STEPS - 1; h++) {
for (let ring = 0; ring < RADIAL_RINGS - 1; ring++) {
for (let seg = 0; seg < THETA_SEGMENTS; seg++) {
const next = (seg + 1) % THETA_SEGMENTS;
const a = idx(h, ring, seg);
const b = idx(h, ring, next);
const e = idx(h + 1, ring, seg);
const f = idx(h + 1, ring, next);
pushTri(a, e, b);
pushTri(e, f, b);
}
}
// center fill
for (let seg = 0; seg < THETA_SEGMENTS; seg++) {
const next = (seg + 1) % THETA_SEGMENTS;
const center0 = idx(h, -1, 0);
const center1 = idx(h + 1, -1, 0);
const a = idx(h, 0, seg);
const b = idx(h, 0, next);
const c = idx(h + 1, 0, seg);
const d = idx(h + 1, 0, next);
pushTri(center0, c, a);
pushTri(center0, center1, c);
pushTri(a, c, b);
pushTri(b, c, d);
}
}
// Close the bottom at the hopper tip only when grain reaches the tip.
const tipVertexIndex = HEIGHT_STEPS * pointsPerLayer;
if (hopperHeight > 0 && grainBottomY <= hopperTipY + 0.01) {
const tipMoisture = idwMoisture(0, hopperTipY, 0, moistureAnchors, IDW_POWER);
const tipHeat = moistureToHeat(tipMoisture, targetMoisture);
const [tr, tg, tb] = heatToRGB(tipHeat);
positions[tipVertexIndex * 3] = 0;
positions[tipVertexIndex * 3 + 1] = hopperTipY;
positions[tipVertexIndex * 3 + 2] = 0;
colors[tipVertexIndex * 3] = tr;
colors[tipVertexIndex * 3 + 1] = tg;
colors[tipVertexIndex * 3 + 2] = tb;
heats[tipVertexIndex] = tipHeat;
const outerRing = RADIAL_RINGS - 1;
for (let seg = 0; seg < THETA_SEGMENTS; seg++) {
const next = (seg + 1) % THETA_SEGMENTS;
pushTri(tipVertexIndex, idx(0, outerRing, next), idx(0, outerRing, seg));
}
}
function makeGeo(indices: number[]) {
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.BufferAttribute(positions, 3));
g.setAttribute("color", new THREE.BufferAttribute(colors, 3));
g.setIndex(indices);
return g;
}
return {
green: makeGeo(green),
yellow: makeGeo(yellow),
red: makeGeo(red),
};
}, [
moistureAnchors,
surfaceAnchors,
wallY,
maxGrainY,
minGrainY,
hopperTipY,
sidewallBaseY,
binRadius,
targetMoisture,
hopperHeight,
roofHeight,
roofTipY,
flatMaxY,
flatMinY,
]);
if (!geometry) return null;
return (
<React.Fragment>
<mesh geometry={geometry.green} renderOrder={1}>
<meshBasicMaterial
vertexColors
transparent
opacity={GREEN_OPACITY}
side={THREE.DoubleSide}
depthWrite={false}
/>
</mesh>
<mesh geometry={geometry.yellow} renderOrder={2}>
<meshBasicMaterial
vertexColors
transparent
opacity={YELLOW_OPACITY}
side={THREE.DoubleSide}
depthWrite={false}
/>
</mesh>
<mesh geometry={geometry.red} renderOrder={3}>
<meshBasicMaterial
vertexColors
transparent
opacity={RED_OPACITY}
side={THREE.DoubleSide}
depthWrite={false}
/>
</mesh>
</React.Fragment>
);
}

View file

@ -0,0 +1,254 @@
import { NodeData } from "bin/3dView/Data/BuildNodeData";
import { colourFade } from "bin/3dView/utils/tempToColour";
import { Bin } from "models";
import { useMemo } from "react";
import { CanvasTexture, NormalBlending } from "three";
interface Props {
bin: Bin;
nodes: NodeData[];
}
export default function NodePointCloud(props: Props) {
const { bin, nodes } = props;
// 🎛️ TUNING KNOBS
const BASE_RADIUS_FACTOR = 0.15;
const INTENSITY_DENSITY_MULT = 1.25;
const INTENSITY_DENSITY_BASE = 0.3;
const RADIAL_BASE = 6;
const THETA_BASE = 10;
const PHI_BASE = 10;
const MIN_EDGE_INTENSITY = 0.2;
const BASE_BRIGHTNESS = 0.7;
const INTENSITY_BRIGHTNESS_MULT = 0.7;
const OPACITY = 0.3;
const POINT_SIZE = 1;
// IDW power — how sharply nearer nodes dominate the field
const IDW_POWER = 2;
// Minimum net signed magnitude to emit a point.
// Keeps near-zero cancellation zones empty rather than noisy.
const EMIT_THRESHOLD = 0.05;
// -----------------------------
// BIN GEOMETRY
// -----------------------------
const binRadius = bin.diameter() / 2;
const sidewallHeight = bin.sidewallHeight();
const hopperHeight = bin.hopperHeight() ?? 0;
const sidewallBaseY = -sidewallHeight / 2;
const hopperTipY = sidewallBaseY - hopperHeight;
const maxRadiusAtY = (y: number): number => {
if (y >= sidewallBaseY) return binRadius;
if (hopperHeight <= 0 || y <= hopperTipY) return 0;
const t = (y - hopperTipY) / hopperHeight;
return binRadius * t;
};
// -----------------------------
// GRAIN LIMIT
// -----------------------------
const maxGrainY = useMemo(() => {
let maxY = -Infinity;
nodes.forEach((node) => {
if (!node.inGrain || node.excluded) return;
if (node.position.y > maxY) maxY = node.position.y;
});
return maxY;
}, [nodes]);
// -----------------------------
// PRE-COMPUTE SIGNED NODE CONTRIBUTIONS
// Only nodes outside threshold contribute to the field.
// Hot nodes carry a positive signed intensity, cold nodes negative.
// -----------------------------
const signedNodes = useMemo(() => {
return nodes
.filter(n => n.inGrain && !n.excluded)
.map(n => {
const lower = bin.lowerTempThreshold();
const upper = bin.targetTemp();
if (n.celcius >= lower && n.celcius <= upper) return null;
const distance = n.celcius < lower
? lower - n.celcius
: n.celcius - upper;
const intensity = Math.min(1, distance / colourFade);
// positive = hot, negative = cold
const signedIntensity = n.celcius > upper ? intensity : -intensity;
return {
x: n.position.x,
y: n.position.y,
z: n.position.z,
signedIntensity,
cloudRadius: bin.diameter() * BASE_RADIUS_FACTOR * (0.5 + intensity),
densityMultiplier: INTENSITY_DENSITY_BASE + intensity * INTENSITY_DENSITY_MULT,
};
})
.filter(Boolean) as {
x: number; y: number; z: number;
signedIntensity: number;
cloudRadius: number;
densityMultiplier: number;
}[];
}, [nodes, bin]);
/**
* Evaluates the net signed field at (px, py, pz) by summing
* IDW-weighted signed intensities from all contributing nodes.
*
* Returns a value in [-1, 1]:
* > 0 net hot
* < 0 net cold
* ~0 cancelled out point will not be emitted
*/
const evaluateField = (px: number, py: number, pz: number): number => {
let totalWeight = 0;
let weightedSum = 0;
for (const node of signedNodes) {
const dx = px - node.x;
const dy = py - node.y;
const dz = pz - node.z;
const distSq = dx * dx + dy * dy + dz * dz;
const weight = distSq < 0.001
? 1e6
: 1 / Math.pow(distSq, IDW_POWER / 2);
totalWeight += weight;
weightedSum += node.signedIntensity * weight;
}
if (totalWeight === 0) return 0;
return weightedSum / totalWeight;
};
// -----------------------------
// BUILD POINT CLOUD
// -----------------------------
const { positions, colors } = useMemo(() => {
const positions: number[] = [];
const colors: number[] = [];
signedNodes.forEach((node) => {
const radialSteps = Math.floor(RADIAL_BASE * node.densityMultiplier);
const thetaSteps = Math.floor(THETA_BASE * node.densityMultiplier);
const phiSteps = Math.floor(PHI_BASE * node.densityMultiplier);
for (let rStep = 0; rStep < radialSteps; rStep++) {
const r = Math.sqrt(rStep / radialSteps) * node.cloudRadius;
for (let pStep = 0; pStep < phiSteps; pStep++) {
const phi = ((pStep + 0.5) / phiSteps) * Math.PI;
for (let tStep = 0; tStep < thetaSteps; tStep++) {
const theta = (tStep / thetaSteps) * Math.PI * 2;
const x = node.x + r * Math.sin(phi) * Math.cos(theta);
const y = node.y + r * Math.cos(phi);
const z = node.z + r * Math.sin(phi) * Math.sin(theta);
// Bin boundary checks
if (y < hopperTipY || y > maxGrainY) continue;
const distXZ = Math.sqrt(x * x + z * z);
if (distXZ > maxRadiusAtY(y)) continue;
// Evaluate the full signed field at this point.
// A point in the overlap of a hot and cold cloud will have
// a net value near zero and will be skipped rather than
// rendered pink.
const netField = evaluateField(x, y, z);
if (Math.abs(netField) < EMIT_THRESHOLD) continue;
positions.push(x, y, z);
// Brightness driven by net magnitude, not per-node intensity
const netMagnitude = Math.abs(netField);
const falloff = 1 - r / node.cloudRadius;
const spatialFalloff = Math.pow(Math.max(0, falloff), 2);
const adjustedFalloff = MIN_EDGE_INTENSITY + (1 - MIN_EDGE_INTENSITY) * spatialFalloff;
const intensityCurve = netMagnitude * netMagnitude;
const brightness = adjustedFalloff * (BASE_BRIGHTNESS + INTENSITY_BRIGHTNESS_MULT * intensityCurve);
if (netField > 0) {
colors.push(brightness, 0, 0); // hot → red
} else {
colors.push(0, 0, brightness); // cold → blue
}
}
}
}
});
return {
positions: new Float32Array(positions),
colors: new Float32Array(colors),
};
}, [signedNodes, maxGrainY, hopperTipY]);
const circleTexture = useMemo(() => {
const size = 64;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d")!;
const gradient = ctx.createRadialGradient(
size / 2, size / 2, 0,
size / 2, size / 2, size / 2
);
gradient.addColorStop(0, "rgba(255,255,255,1)");
gradient.addColorStop(1, "rgba(255,255,255,0)");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size, size);
const texture = new CanvasTexture(canvas);
texture.needsUpdate = true;
return texture;
}, []);
// -----------------------------
// RENDER
// -----------------------------
return (
<points renderOrder={2}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
array={positions}
count={positions.length / 3}
itemSize={3}
/>
<bufferAttribute
attach="attributes-color"
array={colors}
count={colors.length / 3}
itemSize={3}
/>
</bufferGeometry>
<pointsMaterial
size={POINT_SIZE}
map={circleTexture}
vertexColors
transparent
opacity={OPACITY}
depthWrite={false}
blending={NormalBlending}
/>
</points>
);
}

View file

@ -0,0 +1,470 @@
import { useMemo } from "react";
import * as THREE from "three";
import { Bin } from "models";
import { NodeData } from "../../Data/BuildNodeData";
import React from "react";
import { BinDims } from "bin/3dView/Data/BuildCableData";
interface Props {
binDimensions: BinDims
targetTemp: number
nodes: NodeData[];
/**
* For flat fill percent inventory a scalar Y ceiling for the heatmap top.
* Used when there are no cable top nodes to drive a surface.
*/
flatMaxY?: number;
/** For flat fill percent — Y floor of filled grain (hopper tip or cylinder base). */
flatMinY?: number;
}
const RADIAL_RINGS = 20;
const THETA_SEGMENTS = 40;
const HEIGHT_STEPS = 28;
const YELLOW_DELTA = 5;
const RED_DELTA = 10;
const IDW_POWER = 4;
const RED_OPACITY = 0.3;
const GREEN_OPACITY = 0.3;
const YELLOW_OPACITY = 0.8;
const ANGLE_JITTER = 0.2;
const RADIAL_JITTER = 0.05;
const LAYER_TWIST = 0.22;
// IDW power for the horizontal surface interpolation —
// matches GrainCableFill's default of 2
const SURFACE_IDW_POWER = 2;
function tempToHeat(temp: number, upper: number): number {
if (temp <= upper) return 0;
const delta = temp - upper;
if (delta >= RED_DELTA) return 2;
if (delta >= YELLOW_DELTA) return 1 + (delta - YELLOW_DELTA) / (RED_DELTA - YELLOW_DELTA);
return delta / YELLOW_DELTA;
}
function heatToRGB(heat: number): number[] {
const GREEN = [0, 0.5, 0.02];
const YELLOW = [0.7, 0.86, 0.0];
const RED = [0.8, 0.0, 0.01];
if (heat <= 0) return GREEN;
if (heat <= 1) {
const t = Math.pow(heat, 0.75);
return [
GREEN[0] + (YELLOW[0] - GREEN[0]) * t,
GREEN[1] + (YELLOW[1] - GREEN[1]) * t,
GREEN[2] + (YELLOW[2] - GREEN[2]) * t,
];
}
const t = Math.pow(heat - 1, 0.75);
return [
YELLOW[0] + (RED[0] - YELLOW[0]) * t,
YELLOW[1] + (RED[1] - YELLOW[1]) * t,
YELLOW[2] + (RED[2] - YELLOW[2]) * t,
];
}
interface TempAnchor {
x: number; y: number; z: number;
celcius: number;
}
interface SurfaceAnchor {
x: number; z: number; y: number;
}
// 3D IDW for temperature interpolation
function idwTemp(
px: number, py: number, pz: number,
anchors: TempAnchor[],
power: number
): number {
let totalWeight = 0;
let weightedSum = 0;
for (const a of anchors) {
const dx = px - a.x;
const dy = py - a.y;
const dz = pz - a.z;
const distSq = dx * dx + dy * dy + dz * dz;
if (distSq < 0.001) return a.celcius;
const weight = 1 / Math.pow(distSq, power / 2);
totalWeight += weight;
weightedSum += a.celcius * weight;
}
return totalWeight === 0 ? 0 : weightedSum / totalWeight;
}
// 2D IDW for surface height interpolation — matches GrainCableFill exactly
function idwSurfaceY(
x: number, z: number,
anchors: SurfaceAnchor[],
power: number
): number {
let totalWeight = 0;
let weightedY = 0;
for (const a of anchors) {
const dx = x - a.x;
const dz = z - a.z;
const distSq = dx * dx + dz * dz;
if (distSq < 0.001) return a.y;
const weight = 1 / Math.pow(distSq, power / 2);
totalWeight += weight;
weightedY += a.y * weight;
}
return totalWeight === 0 ? 0 : weightedY / totalWeight;
}
function hashNoise(a: number, b: number, c: number) {
const x = Math.sin(a * 127.1 + b * 311.7 + c * 74.7) * 43758.5453;
return (x - Math.floor(x)) * 2 - 1;
}
export default function TempHeatMap({ binDimensions, targetTemp, nodes, flatMaxY, flatMinY }: Props) {
const binRadius = binDimensions.diameter / 2;
const sidewallHeight = binDimensions.sidewallHeight;
const hopperHeight = binDimensions.hopperHeight ?? 0;
const roofHeight = binDimensions.roofHeight ?? 0;
const upperThreshold = targetTemp;
const sidewallBaseY = -sidewallHeight / 2;
const sidewallTopY = sidewallHeight / 2;
const hopperTipY = sidewallBaseY - hopperHeight;
const roofTipY = sidewallTopY + roofHeight;
const maxRadiusAtY = (y: number) => {
// Inside the roof cone — tapers from binRadius at sidewallTopY to 0 at roofTipY
if (y > sidewallTopY) {
if (roofHeight <= 0 || y >= roofTipY) return 0;
return binRadius * (1 - (y - sidewallTopY) / roofHeight);
}
// Straight sidewall
if (y >= sidewallBaseY) return binRadius;
// Inside the hopper cone — tapers from binRadius at sidewallBaseY to 0 at hopperTipY
if (hopperHeight <= 0 || y <= hopperTipY) return 0;
return binRadius * ((y - hopperTipY) / hopperHeight);
};
// Temperature anchors — all in-grain nodes
const tempAnchors = useMemo<TempAnchor[]>(
() =>
nodes
.filter(n => n.inGrain && !n.excluded)
.map(n => ({
x: n.position.x,
y: n.position.y,
z: n.position.z,
celcius: n.celcius,
})),
[nodes]
);
// Surface anchors — top nodes only, Y offset by half spacing.
// Only used when flatMaxY is NOT provided (i.e. Automatic / Hybrid_Cable
// inventory control). For any other control type the caller passes flatMaxY
// and we must use a flat surface instead of the wavy cable-driven one.
const surfaceAnchors = useMemo<SurfaceAnchor[]>(() => {
if (flatMaxY !== undefined) {
// Non-cable inventory: force flat surface — ignore cable top nodes
return [];
}
return nodes
.filter(n => n.topNode && n.inGrain && !n.excluded)
.map(n => ({
x: n.position.x,
z: n.position.z,
y: n.position.y + n.nodeSpacing * 0.5,
}));
}, [nodes, flatMaxY]);
// Wall clamp Y — average of surface anchor heights.
// Prevents the surface from piling up at the bin wall where there are no cables.
// Matches GrainCableFill's wallY computation.
const wallY = useMemo(() => {
if (surfaceAnchors.length === 0) return flatMaxY ?? sidewallBaseY;
return surfaceAnchors.reduce((sum, a) => sum + a.y, 0) / surfaceAnchors.length;
}, [surfaceAnchors, flatMaxY, sidewallBaseY]);
// Lowest Y the grain surface or heatmap may occupy (hopper tip or cylinder floor).
const grainFloorY = hopperHeight > 0 ? hopperTipY : sidewallBaseY;
// For each (x, z) position, returns the Y ceiling of the grain surface.
// Uses cable surface IDW when top nodes exist, falls back to flatMaxY.
const getSurfaceY = (x: number, z: number): number => {
if (surfaceAnchors.length > 0) {
const raw = idwSurfaceY(x, z, surfaceAnchors, SURFACE_IDW_POWER);
return Math.max(grainFloorY, Math.min(roofTipY, raw));
}
return flatMaxY ?? sidewallBaseY;
};
// Global max Y — highest point of the surface (used for grid bottom calc)
const maxGrainY = useMemo(() => {
if (surfaceAnchors.length > 0) {
return Math.max(...surfaceAnchors.map(a => a.y), wallY);
}
return flatMaxY ?? sidewallBaseY;
}, [surfaceAnchors, wallY, flatMaxY, sidewallBaseY]);
// Bottom of filled grain. Flat fill uses volume bounds; cable fill rests on
// the hopper tip (matches GrainCableFill) rather than the lowest cable node.
const minGrainY = useMemo(() => {
if (flatMinY !== undefined) return flatMinY;
return grainFloorY;
}, [flatMinY, grainFloorY]);
const geometry = useMemo(() => {
if (!tempAnchors.length) return null;
const pointsPerLayer = 1 + RADIAL_RINGS * THETA_SEGMENTS;
const totalVerts = HEIGHT_STEPS * pointsPerLayer + 1;
const positions = new Float32Array(totalVerts * 3);
const colors = new Float32Array(totalVerts * 3);
const heats = new Float32Array(totalVerts);
const grainBottomY = minGrainY;
const grainHeight = maxGrainY - grainBottomY;
if (grainHeight <= 0) return null;
// Pre-compute the (x, z) position for each (ring, seg) slot so we
// can look up the surface Y ceiling per column
const colX: number[] = new Array(RADIAL_RINGS * THETA_SEGMENTS);
const colZ: number[] = new Array(RADIAL_RINGS * THETA_SEGMENTS);
const colSurfaceY: number[] = new Array(RADIAL_RINGS * THETA_SEGMENTS);
// Use the outermost layer's radius for surface Y lookup (no jitter/twist)
// so the surface shape matches GrainCableFill cleanly
const topLayerAllowedRadius = maxRadiusAtY(maxGrainY) * 0.97;
for (let ring = 0; ring < RADIAL_RINGS; ring++) {
const u = (ring + 1) / RADIAL_RINGS;
const rFrac = 1 - Math.pow(1 - u, 2.2);
const r = rFrac * topLayerAllowedRadius;
for (let seg = 0; seg < THETA_SEGMENTS; seg++) {
const angle = (seg / THETA_SEGMENTS) * Math.PI * 2;
const x = Math.cos(angle) * r;
const z = Math.sin(angle) * r;
const ci = ring * THETA_SEGMENTS + seg;
colX[ci] = x;
colZ[ci] = z;
// Outermost ring uses wallY, inner rings use full IDW surface
colSurfaceY[ci] = ring === RADIAL_RINGS - 1
? wallY
: getSurfaceY(x, z);
}
}
// Center column surface Y
const centerSurfaceY = getSurfaceY(0, 0);
for (let hStep = 0; hStep < HEIGHT_STEPS; hStep++) {
const t = hStep / (HEIGHT_STEPS - 1);
const layerBase = hStep * pointsPerLayer;
const allowedRadius = maxRadiusAtY(grainBottomY + t * grainHeight) * 0.97;
// Center vertex — Y is lerped from bottom to its column surface ceiling
const centerY = grainBottomY + t * (centerSurfaceY - grainBottomY);
const centerTemp = idwTemp(0, centerY, 0, tempAnchors, IDW_POWER);
const centerHeat = tempToHeat(centerTemp, upperThreshold);
const [cr, cg, cb] = heatToRGB(centerHeat);
positions[layerBase * 3] = 0;
positions[layerBase * 3 + 1] = centerY;
positions[layerBase * 3 + 2] = 0;
colors[layerBase * 3] = cr;
colors[layerBase * 3 + 1] = cg;
colors[layerBase * 3 + 2] = cb;
heats[layerBase] = centerHeat;
for (let ring = 0; ring < RADIAL_RINGS; ring++) {
const u = (ring + 1) / RADIAL_RINGS;
const rFrac = 1 - Math.pow(1 - u, 2.2);
for (let seg = 0; seg < THETA_SEGMENTS; seg++) {
const noiseA = hashNoise(hStep, ring, seg);
const noiseR = hashNoise(seg, hStep, ring + 11);
const baseRadius = rFrac * allowedRadius;
const jitterRadius = baseRadius + noiseR * RADIAL_JITTER * allowedRadius;
const r = Math.max(0, Math.min(jitterRadius, allowedRadius));
const layerPhase = t * LAYER_TWIST;
const angle =
(seg / THETA_SEGMENTS) * Math.PI * 2 +
layerPhase +
noiseA * ANGLE_JITTER;
const x = Math.cos(angle) * r;
const z = Math.sin(angle) * r;
// Per-column surface Y ceiling — this is what gives the wavy top
const ci = ring * THETA_SEGMENTS + seg;
const surfaceY = colSurfaceY[ci];
// Lerp this vertex Y from grainBottomY up to its column surface ceiling
const y = grainBottomY + t * (surfaceY - grainBottomY);
const temp = idwTemp(x, y, z, tempAnchors, IDW_POWER);
const heat = tempToHeat(temp, upperThreshold);
const [vr, vg, vb] = heatToRGB(heat);
const vi = layerBase + 1 + ring * THETA_SEGMENTS + seg;
heats[vi] = heat;
positions[vi * 3] = x;
positions[vi * 3 + 1] = y;
positions[vi * 3 + 2] = z;
colors[vi * 3] = vr;
colors[vi * 3 + 1] = vg;
colors[vi * 3 + 2] = vb;
}
}
}
const idx = (hStep: number, ring: number, seg: number) => {
if (ring < 0) return hStep * pointsPerLayer;
const s = ((seg % THETA_SEGMENTS) + THETA_SEGMENTS) % THETA_SEGMENTS;
return hStep * pointsPerLayer + 1 + ring * THETA_SEGMENTS + s;
};
const green: number[] = [];
const yellow: number[] = [];
const red: number[] = [];
function pushTri(a: number, b: number, c: number) {
const avg = (heats[a] + heats[b] + heats[c]) / 3;
if (avg < 1) green.push(a, b, c);
else if (avg < 2) yellow.push(a, b, c);
else red.push(a, b, c);
}
for (let h = 0; h < HEIGHT_STEPS - 1; h++) {
for (let ring = 0; ring < RADIAL_RINGS - 1; ring++) {
for (let seg = 0; seg < THETA_SEGMENTS; seg++) {
const next = (seg + 1) % THETA_SEGMENTS;
const a = idx(h, ring, seg);
const b = idx(h, ring, next);
const e = idx(h + 1, ring, seg);
const f = idx(h + 1, ring, next);
pushTri(a, e, b);
pushTri(e, f, b);
}
}
// center fill
for (let seg = 0; seg < THETA_SEGMENTS; seg++) {
const next = (seg + 1) % THETA_SEGMENTS;
const center0 = idx(h, -1, 0);
const center1 = idx(h + 1, -1, 0);
const a = idx(h, 0, seg);
const b = idx(h, 0, next);
const c = idx(h + 1, 0, seg);
const d = idx(h + 1, 0, next);
pushTri(center0, c, a);
pushTri(center0, center1, c);
pushTri(a, c, b);
pushTri(b, c, d);
}
}
// Close the bottom at the hopper tip only when grain reaches the tip.
const tipVertexIndex = HEIGHT_STEPS * pointsPerLayer;
if (hopperHeight > 0 && grainBottomY <= hopperTipY + 0.01) {
const tipTemp = idwTemp(0, hopperTipY, 0, tempAnchors, IDW_POWER);
const tipHeat = tempToHeat(tipTemp, upperThreshold);
const [tr, tg, tb] = heatToRGB(tipHeat);
positions[tipVertexIndex * 3] = 0;
positions[tipVertexIndex * 3 + 1] = hopperTipY;
positions[tipVertexIndex * 3 + 2] = 0;
colors[tipVertexIndex * 3] = tr;
colors[tipVertexIndex * 3 + 1] = tg;
colors[tipVertexIndex * 3 + 2] = tb;
heats[tipVertexIndex] = tipHeat;
const outerRing = RADIAL_RINGS - 1;
for (let seg = 0; seg < THETA_SEGMENTS; seg++) {
const next = (seg + 1) % THETA_SEGMENTS;
pushTri(tipVertexIndex, idx(0, outerRing, next), idx(0, outerRing, seg));
}
}
function makeGeo(indices: number[]) {
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.BufferAttribute(positions, 3));
g.setAttribute("color", new THREE.BufferAttribute(colors, 3));
g.setIndex(indices);
return g;
}
return {
green: makeGeo(green),
yellow: makeGeo(yellow),
red: makeGeo(red),
};
}, [
tempAnchors,
surfaceAnchors,
wallY,
maxGrainY,
minGrainY,
hopperTipY,
sidewallBaseY,
binRadius,
upperThreshold,
hopperHeight,
roofHeight,
roofTipY,
flatMaxY,
flatMinY,
]);
if (!geometry) return null;
return (
<React.Fragment>
<mesh geometry={geometry.green} renderOrder={1}>
<meshBasicMaterial
vertexColors
transparent
opacity={GREEN_OPACITY}
side={THREE.DoubleSide}
depthWrite={false}
/>
</mesh>
<mesh geometry={geometry.yellow} renderOrder={2}>
<meshBasicMaterial
vertexColors
transparent
opacity={YELLOW_OPACITY}
side={THREE.DoubleSide}
depthWrite={false}
/>
</mesh>
<mesh geometry={geometry.red} renderOrder={3}>
<meshBasicMaterial
vertexColors
transparent
opacity={RED_OPACITY}
side={THREE.DoubleSide}
depthWrite={false}
/>
</mesh>
</React.Fragment>
);
}

View file

@ -0,0 +1,353 @@
import { useMemo } from "react";
import * as THREE from "three";
import { NodeData } from "../../Data/BuildNodeData";
import Cone from "3dModels/Shapes/3D/Cone";
import { Vector3, Euler } from "three";
import Cylinder from "3dModels/Shapes/3D/Cylinder";
interface Props {
diameter: number;
sidewallHeight: number;
hopperHeight?: number;
nodes: NodeData[];
grainOpacity?: number
/**
* Fallback flat fill percent (01) used when no top nodes are available.
* If undefined and no top nodes exist, nothing is rendered.
*/
fallbackFillPercent?: number;
colour?: string
}
// Tuning knobs
const RADIAL_RINGS = 24; // vertex rings radiating outward from center
const THETA_SEGMENTS = 36; // vertices around each ring
/**
* Inverse-distance weighted interpolation of Y height at a given (x, z) point.
* Each top node contributes a weighted Y based on its horizontal distance from the point.
* The power parameter controls how sharply nearer nodes dominate (2 = standard IDW).
*/
function idwHeight(
x: number,
z: number,
anchors: { x: number; z: number; y: number }[],
power = 2
): number {
let totalWeight = 0;
let weightedY = 0;
for (const anchor of anchors) {
const dx = x - anchor.x;
const dz = z - anchor.z;
const distSq = dx * dx + dz * dz;
// If we're sitting exactly on an anchor, return its Y immediately
if (distSq < 0.001) return anchor.y;
const weight = 1 / Math.pow(distSq, power / 2);
totalWeight += weight;
weightedY += anchor.y * weight;
}
return weightedY / totalWeight;
}
export default function GrainCableFill(props: Props) {
const {
diameter,
sidewallHeight,
hopperHeight = 0,
nodes,
fallbackFillPercent,
grainOpacity,
colour
} = props;
const binRadius = diameter / 2;
// Slightly inset to avoid z-fighting with the shell
const grainRadius = binRadius * 0.98;
// --- Collect top nodes (non-excluded, inGrain) ---
const topNodes = useMemo(() =>
nodes.filter(n => n.topNode && n.inGrain && !n.excluded),
[nodes]
);
// --- Build surface anchors: top node Y + half spacing offset ---
// This places the grain line halfway between the top node and the node above it,
// matching the 2D bin view convention.
const anchors = useMemo(() =>
topNodes.map(n => ({
x: n.position.x,
z: n.position.z,
y: n.position.y + n.nodeSpacing * 0.5,
})),
[topNodes]
);
// --- Wall clamp: average of all anchor Y values ---
// Prevents grain from piling up at the wall where there are no cables.
const wallY = useMemo(() => {
if (anchors.length === 0) return -sidewallHeight / 2;
return anchors.reduce((sum, a) => sum + a.y, 0) / anchors.length;
}, [anchors, sidewallHeight]);
// --- Build the polar surface mesh ---
const surfaceGeometry = useMemo(() => {
if (anchors.length === 0) return null;
// Total vertices: center point + (RADIAL_RINGS * THETA_SEGMENTS) ring vertices
const ringCount = RADIAL_RINGS;
const segCount = THETA_SEGMENTS;
const vertexCount = 1 + ringCount * segCount;
const positions = new Float32Array(vertexCount * 3);
const normals = new Float32Array(vertexCount * 3);
const uvs = new Float32Array(vertexCount * 2);
// Center vertex
const centerY = idwHeight(0, 0, anchors);
positions[0] = 0;
positions[1] = centerY;
positions[2] = 0;
normals[0] = 0; normals[1] = 1; normals[2] = 0;
uvs[0] = 0.5; uvs[1] = 0.5;
// Ring vertices
for (let ring = 0; ring < ringCount; ring++) {
// sqrt distribution for even area density across rings
const t = Math.sqrt((ring + 1) / ringCount);
const r = t * grainRadius;
for (let seg = 0; seg < segCount; seg++) {
const angle = (seg / segCount) * Math.PI * 2;
const x = Math.cos(angle) * r;
const z = Math.sin(angle) * r;
// Outermost ring clamps to wall average; inner rings interpolate
//const isOuterRing = ring === ringCount - 1;
// const y = idwHeight(x, z, anchors);
const rawY = idwHeight(x, z, anchors);
// Blend outer 20% of radius toward wallY
const edgeStart = 0.8; // start taper at 80% radius
const blendT = Math.max(0, (t - edgeStart) / (1 - edgeStart));
// smoothstep
const s = blendT * blendT * (3 - 2 * blendT);
const y = rawY * (1 - s) + wallY * s;
// Clamp Y to valid range: no higher than roof base, no lower than bin floor
const clampedY = Math.max(
-sidewallHeight / 2,
Math.min(sidewallHeight / 2, y)
);
const vi = (1 + ring * segCount + seg) * 3;
positions[vi] = x;
positions[vi + 1] = clampedY;
positions[vi + 2] = z;
// Approximate normals — pointing up (will look fine for grain)
normals[vi] = 0; normals[vi + 1] = 1; normals[vi + 2] = 0;
const ui = (1 + ring * segCount + seg) * 2;
uvs[ui] = (x / grainRadius) * 0.5 + 0.5;
uvs[ui + 1] = (z / grainRadius) * 0.5 + 0.5;
}
}
// --- Build triangle indices ---
// Center fan: triangles from center point to first ring
const indexList: number[] = [];
for (let seg = 0; seg < segCount; seg++) {
const next = (seg + 1) % segCount;
indexList.push(0, 1 + seg, 1 + next);
}
// Ring quads: two triangles per quad between adjacent rings
for (let ring = 0; ring < ringCount - 1; ring++) {
for (let seg = 0; seg < segCount; seg++) {
const next = (seg + 1) % segCount;
const a = 1 + ring * segCount + seg;
const b = 1 + ring * segCount + next;
const c = 1 + (ring + 1) * segCount + seg;
const d = 1 + (ring + 1) * segCount + next;
indexList.push(a, c, b);
indexList.push(b, c, d);
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geo.setAttribute("normal", new THREE.BufferAttribute(normals, 3));
geo.setAttribute("uv", new THREE.BufferAttribute(uvs, 2));
geo.setIndex(indexList);
geo.computeVertexNormals(); // smooth out the approximated normals
return geo;
}, [anchors, wallY, grainRadius, sidewallHeight]);
// --- Hopper fill (reuse existing cone approach) ---
// The surface mesh handles the cylindrical portion.
// The hopper below is always fully filled if the surface is above the bin floor.
const lowestSurfaceY = useMemo(() => {
if (anchors.length === 0) return -sidewallHeight / 2;
return Math.min(...anchors.map(a => a.y), wallY);
}, [anchors, wallY, sidewallHeight]);
const hopperIsActive = hopperHeight > 0 && lowestSurfaceY > -sidewallHeight / 2;
const hopperPosition = useMemo(
() => new Vector3(0, -(sidewallHeight / 2 + hopperHeight / 2), 0),
[sidewallHeight, hopperHeight]
);
const hopperRotation = useMemo(() => new Euler(Math.PI, 0, 0), []);
// --- Fallback: flat fill when no top nodes ---
const fallbackGeometry = useMemo(() => {
if (anchors.length > 0 || fallbackFillPercent === undefined) return null;
const radius = diameter / 2;
const fbRadius = radius * 0.98;
const cylinderVolume = Math.PI * radius * radius * sidewallHeight;
const hopperVolume = hopperHeight > 0
? (1 / 3) * Math.PI * radius * radius * hopperHeight
: 0;
const totalVolume = cylinderVolume + hopperVolume;
const filledVolume = totalVolume * fallbackFillPercent;
let hopperFillHeight = 0;
let cylinderFillHeight = 0;
if (hopperHeight > 0 && filledVolume <= hopperVolume) {
const ratio = filledVolume / hopperVolume;
hopperFillHeight = hopperHeight * Math.pow(ratio, 1 / 3);
cylinderFillHeight = 0;
} else {
hopperFillHeight = hopperHeight;
const remaining = filledVolume - hopperVolume;
cylinderFillHeight = Math.max(0, remaining / (Math.PI * radius * radius));
}
return { fbRadius, hopperFillHeight, cylinderFillHeight };
}, [anchors.length, fallbackFillPercent, diameter, sidewallHeight, hopperHeight]);
// --- Render fallback ---
if (anchors.length === 0) {
if (!fallbackGeometry) return null;
const { fbRadius, hopperFillHeight, cylinderFillHeight } = fallbackGeometry;
return (
<>
{hopperHeight > 0 && hopperFillHeight > 0 && (
<Cone
geometry={{
radius: fbRadius * (hopperFillHeight / hopperHeight),
height: hopperFillHeight,
radialSegments: 20,
openEnded: false,
}}
position={new Vector3(0, -(sidewallHeight / 2 + hopperHeight) + hopperFillHeight / 2, 0)}
rotation={new Euler(Math.PI, 0, 0)}
colour={colour}
roughness={1}
metalness={0}
opacity={grainOpacity}
depthWrite={false}
depthTest={false}
renderOrder={0}
/>
)}
{cylinderFillHeight > 0 && (
<Cylinder
geometry={{
radiusTop: fbRadius,
radiusBottom: fbRadius,
height: cylinderFillHeight,
radialSegments: 20,
heightSegments: 1,
openEnded: false
}}
position={new Vector3(0, -sidewallHeight / 2 + cylinderFillHeight / 2, 0)}
colour={colour}
roughness={1}
metalness={0}
opacity={grainOpacity}
depthWrite={false}
depthTest={false}
renderOrder={0}
/>
)}
</>
);
}
// --- Render cable-driven surface ---
return (
<>
{/* Interpolated grain surface - i have not made a react component for this shape, not exactly a basic shape, so am just using mesh as is */}
{surfaceGeometry && (
<mesh renderOrder={0} geometry={surfaceGeometry}>
<meshStandardMaterial
color={colour}
roughness={1}
metalness={0}
side={THREE.DoubleSide}
transparent
opacity={grainOpacity}
depthWrite={false}
depthTest={false}
/>
</mesh>
)}
{/* Cylindrical body of grain below the surface down to the bin floor / hopper top */}
<Cylinder
position={new Vector3(0, -sidewallHeight / 2 + wallY / 2 + sidewallHeight / 4, 0)}
geometry={{
height: sidewallHeight / 2 + wallY,
radiusBottom: grainRadius,
radiusTop: grainRadius,
radialSegments: THETA_SEGMENTS,
heightSegments: 1,
openEnded: false
}}
colour={colour}
roughness={1}
opacity={grainOpacity}
depthWrite={false}
depthTest={false}
renderOrder={0}
/>
{/* Hopper fill — always full when grain surface exists above the floor */}
{hopperIsActive && (
<Cone
geometry={{
radius: grainRadius,
height: hopperHeight,
radialSegments: THETA_SEGMENTS,
openEnded: false,
}}
position={hopperPosition}
rotation={hopperRotation}
colour={colour}
roughness={1}
metalness={0}
opacity={grainOpacity}
depthWrite={false}
depthTest={false}
renderOrder={0}
/>
)}
</>
);
}

View file

@ -0,0 +1,132 @@
import React, { useMemo } from "react";
import Cylinder from "3dModels/Shapes/3D/Cylinder";
import Cone from "3dModels/Shapes/3D/Cone";
import { Vector3, Euler } from "three";
interface Props {
diameter: number;
sidewallHeight: number;
hopperHeight?: number;
fillPercent: number;
grainOpacity?: number;
colour?: string
}
export default function GrainFillFlat(props: Props) {
const {
diameter,
sidewallHeight,
hopperHeight = 0,
fillPercent,
grainOpacity,
colour
} = props;
const radius = diameter / 2;
// Slightly smaller to avoid z-fighting with shell
const grainRadius = radius * 0.98;
// --- volumes ---
const cylinderVolume = Math.PI * radius * radius * sidewallHeight;
const hopperVolume = hopperHeight > 0
? (1 / 3) * Math.PI * radius * radius * hopperHeight
: 0;
const totalVolume = cylinderVolume + hopperVolume;
const filledVolume = totalVolume * fillPercent;
// --- result values ---
let hopperFillHeight = 0;
let cylinderFillHeight = 0;
if (hopperHeight > 0 && filledVolume <= hopperVolume) {
// ONLY hopper filling
const ratio = filledVolume / hopperVolume;
hopperFillHeight = hopperHeight * Math.pow(ratio, 1 / 3);
cylinderFillHeight = 0;
} else {
// hopper full (or no hopper)
hopperFillHeight = hopperHeight;
const remainingVolume = filledVolume - hopperVolume;
cylinderFillHeight = Math.max(
0,
remainingVolume / (Math.PI * radius * radius)
);
}
// --- positions ---
const hopperPosition = useMemo(
() => new Vector3(
0,
-(sidewallHeight / 2 + hopperHeight) + hopperFillHeight / 2, //adding the hopper height so it starts at the cone and not the flat side
0
),
[sidewallHeight, hopperFillHeight]
);
//flip the cone so it matches the hoppers rotation with the cone pointed down
const hopperRotation = useMemo(
() => new Euler(Math.PI, 0, 0),
[]
);
const cylinderPosition = useMemo(
() => new Vector3(
0,
-sidewallHeight / 2 + cylinderFillHeight / 2,
0
),
[sidewallHeight, cylinderFillHeight]
);
return (
<>
{/* Hopper fill */}
{hopperHeight > 0 && hopperFillHeight > 0 && (
<Cone
geometry={{
radius: grainRadius * (hopperFillHeight / hopperHeight),
height: hopperFillHeight,
radialSegments: 20,
openEnded: false
}}
position={hopperPosition}
rotation={hopperRotation}
colour={colour}
roughness={1}
metalness={0}
opacity={grainOpacity}
depthWrite={false}
depthTest={false}
renderOrder={0}
/>
)}
{/* Cylinder fill */}
{cylinderFillHeight > 0 && (
<Cylinder
geometry={{
radiusTop: grainRadius,
radiusBottom: grainRadius,
height: cylinderFillHeight,
radialSegments: 20,
openEnded: false
}}
position={cylinderPosition}
colour={colour}
roughness={1}
metalness={0}
opacity={grainOpacity}
depthWrite={false}
depthTest={false}
renderOrder={0}
/>
)}
</>
);
}

View file

@ -0,0 +1,71 @@
import { ConeVolume, CylinderVolume } from "common/TrigFunctions";
/**
* Y bounds of filled grain volume matches GrainFillFlat / GrainCableFill fallback volume math.
*/
export interface GrainYBounds {
minY: number;
maxY: number;
}
// Grain never physically fills all the way to the roof tip — cap roof fill
// at this fraction of roofHeight so there is always visible breathing room.
const MAX_ROOF_FILL_FRACTION = 0.75;
export function grainYBoundsFromFillPercent(
diameter: number,
sidewallHeight: number,
hopperHeight: number,
roofHeight: number,
fillPercent: number
): GrainYBounds {
const radius = diameter / 2;
const sidewallBaseY = -sidewallHeight / 2;
const hopperTipY = sidewallBaseY - hopperHeight;
const roofBaseY = sidewallHeight / 2;
if (fillPercent <= 0) {
return { minY: sidewallBaseY, maxY: sidewallBaseY };
}
const cylinderVolume = CylinderVolume(radius, sidewallHeight);
const hopperVolume = hopperHeight > 0 ? ConeVolume(radius, hopperHeight) : 0;
const roofVolume = roofHeight > 0 ? ConeVolume(radius, roofHeight) : 0;
// Volume of the usable roof portion (up to MAX_ROOF_FILL_FRACTION of height).
// A cone fills as h³ so the capped volume fraction = MAX_ROOF_FILL_FRACTION³.
const roofVolumeCapped = roofVolume * Math.pow(MAX_ROOF_FILL_FRACTION, 3);
const totalVolume = cylinderVolume + hopperVolume + roofVolumeCapped;
const filledVolume = totalVolume * fillPercent;
// Fill the hopper first
if (hopperHeight > 0 && filledVolume <= hopperVolume) {
const ratio = filledVolume / hopperVolume;
const hopperFillHeight = hopperHeight * Math.pow(ratio, 1 / 3);
return {
minY: hopperTipY,
maxY: hopperTipY + hopperFillHeight,
};
}
// Then fill the cylinder
const volumeAfterHopper = filledVolume - hopperVolume;
if (volumeAfterHopper <= cylinderVolume) {
const cylinderFillHeight = volumeAfterHopper / (Math.PI * radius * radius);
return {
minY: hopperHeight > 0 ? hopperTipY : sidewallBaseY,
maxY: sidewallBaseY + cylinderFillHeight,
};
}
// Finally fill into the roof cone, capped at MAX_ROOF_FILL_FRACTION
const roofFilledVolume = volumeAfterHopper - cylinderVolume;
const roofRatio = Math.min(1, roofFilledVolume / roofVolumeCapped);
const roofFillHeight = roofHeight * MAX_ROOF_FILL_FRACTION * Math.pow(roofRatio, 1 / 3);
return {
minY: hopperHeight > 0 ? hopperTipY : sidewallBaseY,
maxY: roofBaseY + roofFillHeight,
};
}

View file

@ -0,0 +1,68 @@
import { Color } from "three";
import { NodeData } from "../Data/BuildNodeData";
type ColourMode = "node" | "heatmap";
const GREEN = new Color("#52c41a");
const BLUE = new Color("#3399ff");
const RED = new Color("#ff4d4f");
export const colourFade = 20;
const minimumLightness = 0.3;
const lightnessRange = 0.2;
const minimumSaturation = 0.7;
const saturationRange = 0.8;
const getTemperatureIntensity = (temp: number, lower: number, upper: number) => {
if (temp >= lower && temp <= upper) return 0;
if (temp < lower) return lower - temp;
return temp - upper;
}
const getVisualIntensity = (distance: number, mode: ColourMode) => {
// 1 = near threshold, 0 = far
const intensity = Math.min(1, distance / colourFade);
switch(mode){
case "node":
return 1 - intensity
case "heatmap":
return intensity
}
};
export function TempToColour(
node: NodeData,
lower: number,
upper: number,
mode: ColourMode
): Color | null {
if (!node.inGrain || node.excluded) return null;
const temp = node.celcius;
// in-threshold behavior
if (temp >= lower && temp <= upper) {
return mode === "heatmap" ? null : GREEN;
}
const distance = getTemperatureIntensity(temp, lower, upper);
const intensity = getVisualIntensity(distance, mode);
const color = new Color();
const hsl = { h: 0, s: 1, l: 1 };
if (temp < lower) {
BLUE.getHSL(hsl);
} else {
RED.getHSL(hsl);
}
color.setHSL(
hsl.h,
(saturationRange * intensity) + minimumSaturation,
(lightnessRange * intensity) + minimumLightness
);
return color;
}

View file

@ -28,6 +28,7 @@ const useStyles = makeStyles((theme: Theme) => {
position: "fixed",
bottom: theme.spacing(8), //for mobile navigator
right: theme.spacing(2),
zIndex: theme.zIndex.speedDial,
[theme.breakpoints.up("sm")]: {
bottom: theme.spacing(1.75)
}

View file

@ -57,6 +57,8 @@ interface Props {
components?: Map<string, Component>;
setComponents?: React.Dispatch<React.SetStateAction<Map<string, Component>>>;
updateBinStatus?: (componentKeys: string[], removed?: boolean) => void;
componentDevices?: Map<string, number>
setComponentDevices?: React.Dispatch<React.SetStateAction<Map<string, number>>>;
}
interface OpenState {
@ -77,7 +79,9 @@ export default function BinActions(props: Props) {
refreshCallback,
userID,
components,
componentDevices,
setComponents,
setComponentDevices,
updateBinStatus
} = props;
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
@ -245,7 +249,9 @@ export default function BinActions(props: Props) {
open={openState.sensors}
userID={userID}
components={components}
componentDevices={componentDevices}
setComponents={setComponents}
setComponentDevices={setComponentDevices}
updateBinStatus={updateBinStatus}
onClose={refresh => {
if (refresh === true) {

View file

@ -17,7 +17,7 @@ import moment from "moment";
import { pond } from "protobuf-ts/pond";
import { useGlobalState } from "providers";
import React, { useEffect, useState } from "react";
import { celsiusToFahrenheit, getGrainUnit, or } from "utils";
import { celsiusToFahrenheit, or } from "utils";
//import { useHistory } from "react-router";
//import BinModeDot from "./BinModeDot";
import BinSVGV2 from "./BinSVGV2";
@ -444,16 +444,16 @@ export default function BinCard(props: Props) {
);
}
if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && bin.bushelsPerTonne() > 1) {
if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && bin.bushelsPerTonne() > 1) {
return (
bin.grainInventory().toLocaleString() +
bin.grainInventory(user).toLocaleString() +
" mT " +
bin.fillPercent() +
"%"
);
} else if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && bin.bushelsPerTonne() > 1) {
} else if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && bin.bushelsPerTonne() > 1) {
return (
bin.grainInventory().toLocaleString() +
bin.grainInventory(user).toLocaleString() +
" t " +
bin.fillPercent() +
"%"

View file

@ -71,7 +71,9 @@ const useStyles = makeStyles((theme: Theme) => {
interface Props {
components?: Map<string, Component>;
componentDevices?: Map<string, number>;
setComponents?: React.Dispatch<React.SetStateAction<Map<string, Component>>>;
setComponentDevices?: React.Dispatch<React.SetStateAction<Map<string, number>>>;
bin: string;
binGrain: pond.Grain;
updateBinStatus?: (componentKeys: string[], removed?: boolean) => void;
@ -84,7 +86,7 @@ interface Option {
}
export default function BinComponents(props: Props) {
const { components, bin, setComponents, updateBinStatus, binGrain } = props;
const { components, componentDevices, bin, setComponents, setComponentDevices, updateBinStatus, binGrain } = props;
const [{as}] = useGlobalState();
const classes = useStyles();
const binAPI = useBinAPI();
@ -208,20 +210,54 @@ export default function BinComponents(props: Props) {
// }
// }, [selectedDevice, componentAPI, deviceComponents, snackbar]);
const removeComponent = (component: string) => {
binAPI.removeComponent(bin, component, as).then(() => {
const removeComponent = (component: string, device?: number) => {
console.log(device)
binAPI.removeComponent(bin, component, device, as).then(() => {
if (components && setComponents) {
if (components.delete(component)) {
let newComponents = new Map(components);
setComponents(newComponents);
}
}
if(componentDevices && setComponentDevices) {
if (componentDevices.delete(component)) {
let newComponentDevices = new Map(componentDevices);
setComponentDevices(newComponentDevices);
}
}
snackbar.info("Component removed from bin");
});
setComponentToRemove(undefined);
};
const removeComponentConfirmation = () => {
const removeComponentConfirmation = (last?: boolean) => {
let lastComponent = false;
let devId: number | undefined;
if(last){
lastComponent = last
}else{
if (componentDevices && componentToRemove) {
const id = componentDevices.get(componentToRemove.key());
if (id !== undefined) {
devId = id
let count = 0;
for (const val of componentDevices.values()) {
if (val === devId) {
count++;
if (count > 1) {
break; // stop early once we know it's not the last
}
}
}
lastComponent = count === 1;
}
}
}
return (
<ResponsiveDialog
fullScreen={false}
@ -229,19 +265,37 @@ export default function BinComponents(props: Props) {
onClose={() => setComponentToRemove(undefined)}>
<DialogTitle>Remove {componentToRemove?.name()}?</DialogTitle>
<DialogContent>
<DialogContentText>
This will remove {componentToRemove?.name()} from this bin. If you don't have direct
access to this component or the device it is attached to, you will not be able to add it
back.
</DialogContentText>
{lastComponent ?
<DialogContentText>
This component {componentToRemove?.name()} is the last component from its device. You can choose to remove only the component itself or the device along with it.
Leaving the device may have unexpected results if the device will continue to be used and it is recommended to remove it if the device is being moved to a new bin.
</DialogContentText>
:
<DialogContentText>
This will remove {componentToRemove?.name()} from this bin. If you don't have direct
access to this component or the device it is attached to, you will not be able to add it
back.
</DialogContentText>
}
</DialogContent>
<DialogActions>
<Button onClick={() => setComponentToRemove(undefined)} color="primary">
Cancel
</Button>
{lastComponent
?
<React.Fragment>
<Button onClick={() => removeComponent(componentToRemove!.key())} color="primary">
Remove Component
</Button>
<Button onClick={() => removeComponent(componentToRemove!.key(), devId)} color="primary">
Remove Component And Device
</Button>
</React.Fragment> :
<Button onClick={() => removeComponent(componentToRemove!.key())} color="primary">
Remove
</Button>
}
</DialogActions>
</ResponsiveDialog>
);
@ -256,7 +310,7 @@ export default function BinComponents(props: Props) {
<DialogTitle>Remove All Components?</DialogTitle>
<DialogContent>
<DialogContentText>
This will remove All attached components from this bin. If you don't have direct access
This will remove All attached components and devices from this bin. If you don't have direct access
to these components or the devices they are attached to, you will not be able to add
them back.
</DialogContentText>
@ -320,6 +374,12 @@ export default function BinComponents(props: Props) {
setComponents(newComponents);
}
}
if(componentDevices && setComponentDevices) {
if (componentDevices.set(component.key(), device)) {
let newComponentDevices = new Map(componentDevices);
setComponentDevices(newComponentDevices);
}
}
//if a grain cable was added to the bin update the components grain type to match the bin
if (preferences.type === pond.BinComponent.BIN_COMPONENT_GRAIN_CABLE) {
let settings = component.settings;
@ -338,15 +398,40 @@ export default function BinComponents(props: Props) {
}
});
} else {
binAPI.removeComponent(bin, component.key(), as).then(resp => {
if (components && setComponents) {
let lastComponent = false;
if (componentDevices) {
console.log(componentDevices)
let count = 0;
for (const val of componentDevices.values()) {
if (val === device) {
count++;
if (count > 1) {
break; // stop early once we know it's not the last
}
}
}
lastComponent = count === 1;
}
if(lastComponent){
setComponentToRemove(component)
}else{
binAPI.removeComponent(bin, component.key(), undefined, as).then(resp => {
if (components && setComponents) {
if (components.delete(component.key())) {
let newComponents = new Map(components);
setComponents(newComponents);
}
}
if(componentDevices && setComponentDevices) {
if (componentDevices.delete(component.key())) {
let newComponentDevices = new Map(componentDevices);
setComponentDevices(newComponentDevices);
}
}
snackbar.info("Component removed from bin");
});
});
}
}
};
@ -567,7 +652,10 @@ export default function BinComponents(props: Props) {
</ListItemAvatar>
<ListItemText inset={cIcon === undefined}>{component.name()}</ListItemText>
<ListItemSecondaryAction>
<IconButton onClick={() => setComponentToRemove(component)}>
<IconButton onClick={() => {
setComponentToRemove(component)
}}>
<Remove />
</IconButton>
</ListItemSecondaryAction>

View file

@ -21,7 +21,7 @@ import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
import { pond, quack } from "protobuf-ts/pond";
import { useGlobalState, useInteractionsAPI, useSnackbar } from "providers";
import React, { useEffect, useState } from "react";
import { avg, fahrenheitToCelsius, getTemperatureUnit } from "utils";
import { avg, fahrenheitToCelsius } from "utils";
import { makeStyles } from "@mui/styles";
import { Mark } from "@mui/material/Slider/useSlider.types";
@ -103,7 +103,7 @@ export default function BinConditioningInteraction(props: Props) {
let sliderVals: Map<quack.MeasurementType, number> = new Map();
let sliderMarks: Map<quack.MeasurementType, number> = new Map();
passedInteraction.conditions().forEach(condition => {
let describer = describeMeasurement(condition.measurementType, source.type());
let describer = describeMeasurement(condition.measurementType, source.type(), undefined, undefined, user);
//NOTE: toDisplay will convert the temp value to fahrenheit
sliderVals.set(condition.measurementType, describer.toDisplay(condition.value));
});
@ -131,7 +131,7 @@ export default function BinConditioningInteraction(props: Props) {
temp &&
hum
) {
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
//the emc calc needs the temp to be in celsius
temp = fahrenheitToCelsius(temp);
}
@ -139,7 +139,7 @@ export default function BinConditioningInteraction(props: Props) {
setBaseEMC(emc === hum ? undefined : emc);
}
}, [sliderVals, grain]);
}, [sliderVals, grain, user]);
const updateInteraction = () => {
interactionAPI
@ -173,17 +173,17 @@ export default function BinConditioningInteraction(props: Props) {
return (
<Grid container>
{interaction.conditions().map((condition, i) => {
let describer = describeMeasurement(condition.measurementType, source?.type());
let labelTail = "";
let describer = describeMeasurement(condition.measurementType, source?.type(), source.subType(), undefined, user);
let labelTail = describer.unit();
let marks: Mark[] = [];
if (condition.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) {
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
labelTail = "°F";
} else {
labelTail = "°C";
}
}
// if (condition.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) {
// if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
// labelTail = "°F";
// } else {
// labelTail = "°C";
// }
// }
if (condition.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT) {
labelTail = "%";
}
@ -198,7 +198,7 @@ export default function BinConditioningInteraction(props: Props) {
return (
<Grid key={i} container item xs={12} alignContent="center" alignItems="center">
<Grid item xs={12}>
{interactionConditionText(source, condition, false)}
{interactionConditionText(source, condition, false, user)}
</Grid>
<Grid item xs={12} style={{ marginBottom: 20 }}>
<Slider
@ -220,7 +220,7 @@ export default function BinConditioningInteraction(props: Props) {
quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE
) {
if (
getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
) {
return value.toFixed(1) + "°F";
} else {

870
src/bin/BinPlayer.tsx Normal file
View file

@ -0,0 +1,870 @@
import { DateRange, PlayArrow, Stop } from "@mui/icons-material";
import { Box, Button, DialogActions, DialogContent, FormControl, IconButton, LinearProgress, MenuItem, Select, TextField, Typography } from "@mui/material";
import { grey } from "@mui/material/colors";
import ResponsiveDialog from "common/ResponsiveDialog";
import { useMobile } from "hooks";
import { cloneDeep } from "lodash";
import { Bin } from "models";
import moment, { Moment } from "moment";
import { pond } from "protobuf-ts/pond";
import { quack } from "protobuf-ts/quack";
import { useBinAPI, useComponentAPI } from "providers";
import React, { useEffect, useRef, useState } from "react";
const CHECKPOINT_COUNT = 10;
interface StatusCheckpoint {
time: Moment;
timeString: string;
statusBushels: number;
cables: pond.GrainCable[];
fans: pond.BinFan[];
heaters: pond.BinHeater[];
}
interface Props {
bin: Bin;
setBin: React.Dispatch<React.SetStateAction<Bin>>;
componentDevices: Map<string, number>;
}
type ReadingSlot = {
timestamp: string;
values: number[];
};
type CableReadingsAtCheckpoint = {
temperature?: ReadingSlot;
humidity?: ReadingSlot;
moisture?: ReadingSlot;
};
/**
* Per-checkpoint bucket for boolean output components (fans, heaters).
* Only the most recent boolean reading in the time slice is kept.
*/
type BooleanStateAtCheckpoint = {
state?: ReadingSlot; // values[0] is the boolean (1 = on, 0 = off)
};
/** Per-checkpoint bucket: cable key → latest temp/humidity/moisture readings in that time slice. */
type CheckpointReadings = Map<string, CableReadingsAtCheckpoint>;
/** Per-checkpoint bucket: component key → latest boolean state in that time slice. */
type CheckpointBooleanStates = Map<string, BooleanStateAtCheckpoint>;
/**
* Returns true when `candidate` should replace `existing` in a bucket.
* Used so multiple samples in the same checkpoint keep only the newest reading per type.
*/
function readingIsNewer(candidate: ReadingSlot, existing?: ReadingSlot): boolean {
if (!existing) {
return true;
}
return moment(candidate.timestamp).valueOf() > moment(existing.timestamp).valueOf();
}
/**
* Stores one sampled reading on a cable's in-progress bucket state.
* Routes by measurement type: temperature celcius nodes, percent RH humidity nodes,
* grain EMC moisture nodes. Ignores the update if an equal-or-newer reading is already stored.
*/
function upsertReading(
readings: CableReadingsAtCheckpoint,
type: quack.MeasurementType,
timestamp: string,
values: number[]
): void {
const slot: ReadingSlot = { timestamp, values };
if (type === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) {
if (readingIsNewer(slot, readings.temperature)) {
readings.temperature = slot;
}
} else if (type === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT) {
if (readingIsNewer(slot, readings.humidity)) {
readings.humidity = slot;
}
} else if (type === quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC) {
if (readingIsNewer(slot, readings.moisture)) {
readings.moisture = slot;
}
}
}
/**
* A trimmed record of a single component-settings history entry: just the timestamp and the
* top-node value (`grain_filled_to`) that was in effect from that point forward.
*/
type TopNodeHistoryEntry = {
timestamp: Moment;
topNode: number;
};
function buildTopNodeHistory(
history: pond.ComponentHistory[]
): TopNodeHistoryEntry[] {
return history
.filter(h => h.component && h.component.grainFilledTo > 0 && h.timestamp)
.map(h => ({
timestamp: moment(h.timestamp),
topNode: h.component ? h.component.grainFilledTo : 0,
}))
.sort((a, b) => a.timestamp.valueOf() - b.timestamp.valueOf());
}
function topNodeAtTime(
history: TopNodeHistoryEntry[],
checkpointTime: Moment
): number | undefined {
if (history.length === 0) return undefined;
const t = checkpointTime.valueOf();
let lo = 0;
let hi = history.length - 1;
let result: number | undefined = undefined;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
if (history[mid].timestamp.valueOf() <= t) {
result = history[mid].topNode;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return result;
}
/**
* A single recorded bushel estimate with its timestamp, sourced from bin object measurements.
*/
type BushelEntry = {
timestamp: Moment;
bushels: number;
};
function buildBushelHistory(
response: pond.ListObjectMeasurementsResponse
): BushelEntry[] {
const entries: BushelEntry[] = [];
response.measurements.forEach(um => {
const isBushelType =
um.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_BUSHEL_LIDAR ||
um.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_BUSHEL_CABLE ||
um.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_BUSHEL_LIBRACART;
if (!isBushelType) return;
um.timestamps.forEach((ts, i) => {
const valueArray = um.values[i];
if (!ts || !valueArray || valueArray.error || valueArray.values.length === 0) return;
entries.push({
timestamp: moment(ts),
bushels: valueArray.values[0],
});
});
});
return entries.sort((a, b) => a.timestamp.valueOf() - b.timestamp.valueOf());
}
function bushelsAtTime(
history: BushelEntry[],
checkpointTime: Moment
): number | undefined {
if (history.length === 0) return undefined;
const t = checkpointTime.valueOf();
let lo = 0;
let hi = history.length - 1;
let result: number | undefined = undefined;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
if (history[mid].timestamp.valueOf() <= t) {
result = history[mid].bushels;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return result;
}
/**
* Produces `count` wall-clock times evenly spaced from `start` through `end` (inclusive).
* These are the playback frames not derived from measurement data.
*/
function buildCheckpointTimes(start: Moment, end: Moment, count: number): Moment[] {
const startMs = start.valueOf();
const endMs = end.valueOf();
if (count <= 1 || endMs <= startMs) {
return [moment(startMs)];
}
const span = endMs - startMs;
return Array.from({ length: count }, (_, i) =>
moment(startMs + (span * i) / (count - 1))
);
}
/**
* Maps a measurement timestamp to a checkpoint bucket index in [0, count - 1].
* The [start, end] range is split into `count` equal-width segments; a reading lands in
* the segment it falls into. Times before start 0, after end last bucket.
*/
function checkpointIndexForTimestamp(
timestamp: Moment,
start: Moment,
end: Moment,
count: number
): number {
const startMs = start.valueOf();
const endMs = end.valueOf();
if (count <= 1 || endMs <= startMs) {
return 0;
}
const t = timestamp.valueOf();
if (t <= startMs) {
return 0;
}
if (t >= endMs) {
return count - 1;
}
const fraction = (t - startMs) / (endMs - startMs);
const idx = Math.floor(fraction * count);
return Math.min(idx, count - 1);
}
/**
* Creates `count` empty buckets, each pre-populated with an empty reading map per cable key.
* Output length matches checkpoint count; index aligns with `buildCheckpointTimes`.
*/
function emptyCheckpointReadings(cableKeys: string[], count: number): CheckpointReadings[] {
return Array.from({ length: count }, () => {
const map: CheckpointReadings = new Map();
cableKeys.forEach(key => map.set(key, {}));
return map;
});
}
/**
* Creates `count` empty boolean state buckets, each pre-populated per component key.
*/
function emptyCheckpointBooleanStates(
componentKeys: string[],
count: number
): CheckpointBooleanStates[] {
return Array.from({ length: count }, () => {
const map: CheckpointBooleanStates = new Map();
componentKeys.forEach(key => map.set(key, {}));
return map;
});
}
/**
* Walks every sampled measurement from each cable response and places it into a time bucket.
* `measurementResponses[i]` corresponds to `cableKeys[i]` (same order as the sample requests).
* Within a bucket, only the newest reading per measurement type is kept per cable.
*/
function assignMeasurementsToCheckpoints(
cableKeys: string[],
measurementResponses: pond.SampleUnitMeasurementsResponse[],
start: Moment,
end: Moment,
count: number
): CheckpointReadings[] {
const buckets = emptyCheckpointReadings(cableKeys, count);
measurementResponses.forEach((response, cableIndex) => {
const cableKey = cableKeys[cableIndex];
if (!cableKey) {
return;
}
response.measurements.forEach(um => {
um.timestamps.forEach((ts, i) => {
const valueArray = um.values[i];
if (!ts || !valueArray || valueArray.error) {
return;
}
const idx = checkpointIndexForTimestamp(moment(ts), start, end, count);
const readings = buckets[idx].get(cableKey)!;
upsertReading(readings, um.type, ts, valueArray.values);
});
});
});
return buckets;
}
/**
* Walks every sampled measurement from each boolean output component (fan/heater) response
* and places the boolean state into a time bucket. Only the newest MEASUREMENT_TYPE_BOOLEAN
* reading per component per bucket is kept.
*/
function assignBooleanStatesToCheckpoints(
componentKeys: string[],
measurementResponses: pond.SampleUnitMeasurementsResponse[],
start: Moment,
end: Moment,
count: number
): CheckpointBooleanStates[] {
const buckets = emptyCheckpointBooleanStates(componentKeys, count);
measurementResponses.forEach((response, componentIndex) => {
const componentKey = componentKeys[componentIndex];
if (!componentKey) {
return;
}
response.measurements.forEach(um => {
if (um.type !== quack.MeasurementType.MEASUREMENT_TYPE_BOOLEAN) {
return;
}
um.timestamps.forEach((ts, i) => {
const valueArray = um.values[i];
if (!ts || !valueArray || valueArray.error || valueArray.values.length === 0) {
return;
}
const slot: ReadingSlot = { timestamp: ts, values: valueArray.values };
const idx = checkpointIndexForTimestamp(moment(ts), start, end, count);
const entry = buckets[idx].get(componentKey)!;
if (readingIsNewer(slot, entry.state)) {
entry.state = slot;
}
});
});
});
return buckets;
}
/**
* Picks the newest timestamp across temp, humidity, and moisture slots for a cable.
* Used as `lastRead` on the reconstructed `pond.GrainCable`.
*/
function latestTimestamp(readings: CableReadingsAtCheckpoint): string | undefined {
const slots = [readings.temperature, readings.humidity, readings.moisture].filter(
(s): s is ReadingSlot => s !== undefined
);
if (slots.length === 0) {
return undefined;
}
return slots.reduce(
(latest, slot) =>
moment(slot.timestamp).valueOf() > moment(latest).valueOf() ? slot.timestamp : latest,
slots[0].timestamp
);
}
/**
* Combines readings carried forward from earlier checkpoints with readings in the current bucket.
* Any type missing in the current bucket keeps the previous checkpoint's value so playback
* does not drop cables back to template defaults between sparse samples.
*/
function mergeCarriedReadings(
carried: CableReadingsAtCheckpoint,
current: CableReadingsAtCheckpoint
): CableReadingsAtCheckpoint {
return {
temperature: current.temperature ?? carried.temperature,
humidity: current.humidity ?? carried.humidity,
moisture: current.moisture ?? carried.moisture
};
}
/**
* Combines boolean states carried forward from earlier checkpoints with the current bucket.
* If no reading exists in the current bucket the last known state is preserved, so fans/heaters
* don't flicker back to their template default between sparse samples.
*/
function mergeCarriedBooleanState(
carried: BooleanStateAtCheckpoint,
current: BooleanStateAtCheckpoint
): BooleanStateAtCheckpoint {
return {
state: current.state ?? carried.state
};
}
/**
* Clones a live `pond.GrainCable` template (name, key, device, top node, etc.) and overwrites
* node arrays from reconciled readings. `lastRead` is the newest measurement time, or the
* checkpoint time when no readings exist yet for that cable.
*/
function buildGrainCableFromReadings(
template: pond.GrainCable,
readings: CableReadingsAtCheckpoint,
checkpointTime: Moment
): pond.GrainCable {
const cable = pond.GrainCable.fromObject(cloneDeep(template));
// Clear template values so empty checkpoints don't inherit live data
cable.celcius = [];
cable.relativeHumidity = [];
cable.moisture = [];
if (readings.temperature) {
cable.celcius = [...readings.temperature.values];
}
if (readings.humidity) {
cable.relativeHumidity = [...readings.humidity.values];
}
if (readings.moisture) {
cable.moisture = [...readings.moisture.values];
}
cable.lastRead = latestTimestamp(readings) ?? checkpointTime.toISOString();
return cable;
}
/**
* Clones a `pond.BinFan` template and overwrites its `state` from the reconciled boolean
* reading. If no reading has been seen yet the template state is left as-is (carry-forward
* will eventually set it once the first sample arrives).
*/
function buildBinFanFromState(
template: pond.BinFan,
booleanState: BooleanStateAtCheckpoint
): pond.BinFan {
const fan = pond.BinFan.fromObject(cloneDeep(template));
if (booleanState.state !== undefined) {
fan.state = booleanState.state.values[0] !== 0;
}
return fan;
}
/**
* Clones a `pond.BinHeater` template and overwrites its `state` from the reconciled boolean
* reading. Same carry-forward semantics as `buildBinFanFromState`.
*/
function buildBinHeaterFromState(
template: pond.BinHeater,
booleanState: BooleanStateAtCheckpoint
): pond.BinHeater {
const heater = pond.BinHeater.fromObject(cloneDeep(template));
if (booleanState.state !== undefined) {
heater.state = booleanState.state.values[0] !== 0;
}
return heater;
}
/**
* End-to-end pipeline: evenly spaced times bucket measurements carry-forward merge cables + fans + heaters.
* Returns one `StatusCheckpoint` per frame with `time` (playback moment), `cables`
* (full `grainCables` array), `fans`, and `heaters` as they would have appeared at that moment.
*/
function buildStatusCheckpoints(
cableTemplates: pond.GrainCable[],
fanTemplates: pond.BinFan[],
heaterTemplates: pond.BinHeater[],
cableMeasurementResponses: pond.SampleUnitMeasurementsResponse[],
fanMeasurementResponses: pond.SampleUnitMeasurementsResponse[],
heaterMeasurementResponses: pond.SampleUnitMeasurementsResponse[],
/** Sorted top-node history per cable key. Pass an empty Map when not needed. */
topNodeHistories: Map<string, TopNodeHistoryEntry[]>,
/** Sorted bushel history derived from bin object measurements. */
bushelHistory: BushelEntry[],
/** Fallback bushel count used when no measurement precedes a checkpoint. */
fallbackBushels: number,
start: Moment,
end: Moment,
count: number = CHECKPOINT_COUNT
): StatusCheckpoint[] {
const times = buildCheckpointTimes(start, end, count);
console.log(times);
// --- cables ---
const cableKeys = cableTemplates.map(c => c.key);
const cableBuckets = assignMeasurementsToCheckpoints(
cableKeys,
cableMeasurementResponses,
start,
end,
count
);
// --- fans ---
const fanKeys = fanTemplates.map(f => f.key);
const fanBuckets = assignBooleanStatesToCheckpoints(
fanKeys,
fanMeasurementResponses,
start,
end,
count
);
// --- heaters ---
const heaterKeys = heaterTemplates.map(h => h.key);
const heaterBuckets = assignBooleanStatesToCheckpoints(
heaterKeys,
heaterMeasurementResponses,
start,
end,
count
);
// Carry-forward state per component
const carriedByCable = new Map<string, CableReadingsAtCheckpoint>();
cableKeys.forEach(key => carriedByCable.set(key, {}));
const carriedByFan = new Map<string, BooleanStateAtCheckpoint>();
fanKeys.forEach(key => carriedByFan.set(key, {}));
const carriedByHeater = new Map<string, BooleanStateAtCheckpoint>();
heaterKeys.forEach(key => carriedByHeater.set(key, {}));
return cableBuckets.map((cableBucket, i) => {
const time = times[i];
// Build cables
const cables = cableTemplates.map(template => {
const key = template.key;
const bucketReadings = cableBucket.get(key) ?? {};
const merged = mergeCarriedReadings(carriedByCable.get(key)!, bucketReadings);
carriedByCable.set(key, merged);
const cable = buildGrainCableFromReadings(template, merged, time);
// Apply the most recent top-node setting that was in effect at this checkpoint time.
// Falls back to the template's current top_node when no history entry precedes this time.
const history = topNodeHistories.get(key);
if (history) {
const tn = topNodeAtTime(history, time);
if (tn !== undefined) {
cable.topNode = tn;
}
}
return cable;
});
// Build fans
const fans = fanTemplates.map(template => {
const key = template.key;
const bucketState = fanBuckets[i].get(key) ?? {};
const merged = mergeCarriedBooleanState(carriedByFan.get(key)!, bucketState);
carriedByFan.set(key, merged);
return buildBinFanFromState(template, merged);
});
// Build heaters
const heaters = heaterTemplates.map(template => {
const key = template.key;
const bucketState = heaterBuckets[i].get(key) ?? {};
const merged = mergeCarriedBooleanState(carriedByHeater.get(key)!, bucketState);
carriedByHeater.set(key, merged);
return buildBinHeaterFromState(template, merged);
});
// Resolve bushels: binary search gives the most recent recorded value at or before this
// checkpoint (carry-forward is implicit in the search). Falls back to the current live
// status bushels when the playback range predates all recorded measurements.
const statusBushels = bushelsAtTime(bushelHistory, time) ?? fallbackBushels;
return { time, cables, fans, heaters, timeString: time.toISOString(), statusBushels };
}).filter(checkpoint =>
// Keep checkpoints that have at least some cable data
checkpoint.cables.some(cable =>
cable.celcius.length > 0 ||
cable.relativeHumidity.length > 0 ||
cable.moisture.length > 0
)
);
}
/**
* Calculates the number of checkpoints to create based on the start and end dates.
* Is hard coded to space the checkpoints 6 hours apart.
* @param start - The start date
* @param end - The end date
* @returns The number of checkpoints to create
*/
function checkpointCountFromRange(start: Moment, end: Moment): number {
const hours = end.diff(start, 'hours');
return Math.max(1, Math.floor(hours / 6) + 1);
}
/**
* This component is used to reconcile measurements with an array of bin objects in order to see how the bin has progressed over a set period.
* It gets the measurements for connected components, and uses those measurements to create a bin and simulate what its status would have been
* at that time, creates an array of bins doing this and then uses a timer in a loop to change the bin in the status.
* Fans and heaters are included so playback also reflects which equipment was running at each checkpoint.
* @returns a JSX Element
*/
export default function BinPlayer(props: Props) {
const { bin, componentDevices, setBin } = props;
const isMobile = useMobile();
const componentAPI = useComponentAPI();
const binAPI = useBinAPI();
const [dateSelect, setDateSelect] = useState<number>(0);
const [startDate, setStartDate] = useState<Moment>(moment().subtract(1, 'week'));
const [endDate, setEndDate] = useState<Moment>(moment);
const [dateRangeDialog, setDateRangeDialog] = useState(false);
const [tempStartDate, setTempStartDate] = useState<Moment>(moment().subtract(1, 'week'));
const [tempEndDate, setTempEndDate] = useState<Moment>(moment());
const [currentCheckpointTime, setCurrentCheckpointTime] = useState<Moment | undefined>(undefined);
const [displayProgress, setDisplayProgress] = useState(0);
const totalCheckpoints = useRef(0);
const checkpointStartTime = useRef(0);
const currentCheckpointIndex = useRef(0);
const isPlaying = useRef(false);
const PLAYBACK_INTERVAL = 1000;
const [isPlayingState, setIsPlayingState] = useState(false);
const stopPlayer = () => { isPlaying.current = false; };
const originalBin = useRef<Bin | null>(null);
useEffect(() => {
if (!isPlayingState) return;
const fps = 30;
const interval = 1000 / fps;
const stepDuration = PLAYBACK_INTERVAL;
const timer = setInterval(() => {
if (!isPlayingState) {
clearInterval(timer);
setDisplayProgress(0);
return;
}
const total = totalCheckpoints.current;
if (total === 0) return;
const idx = currentCheckpointIndex.current;
const elapsed = Date.now() - checkpointStartTime.current;
const stepProgress = Math.min(elapsed / stepDuration, 1);
const raw = total <= 1 ? 100 : ((idx + stepProgress) / (total - 1)) * 100;
setDisplayProgress(Math.min(raw, 100));
}, interval);
return () => clearInterval(timer);
}, [isPlayingState]);
/**
* Fetches sampled unit measurements for every grain cable, fan, and heater in the current
* bin status, then builds `statusCheckpoints` for playback over [startDate, endDate].
*/
const startPlayer = async () => {
originalBin.current = cloneDeep(bin);
isPlaying.current = true;
setIsPlayingState(true);
// Cable requests
const cableSampleRequests = bin.status.grainCables.map(cable => {
const deviceID = componentDevices.get(cable.key) ?? 0;
return componentAPI.sampleUnitMeasurements(deviceID, cable.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true);
});
// Cable top-node history requests — one per cable, covering the full playback range.
const cableHistoryRequests = bin.status.grainCables.map(cable => {
const deviceID = componentDevices.get(cable.key) ?? 0;
return componentAPI.listHistoryBetween(
deviceID,
cable.key,
startDate.toISOString(),
endDate.toISOString()
);
});
// Fan requests
const fanSampleRequests = bin.status.fans.map(fan => {
const deviceID = componentDevices.get(fan.key) ?? 0;
return componentAPI.sampleUnitMeasurements(deviceID, fan.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true);
});
// Heater requests
const heaterSampleRequests = bin.status.heaters.map(heater => {
const deviceID = componentDevices.get(heater.key) ?? 0;
return componentAPI.sampleUnitMeasurements(deviceID, heater.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true);
});
const binMeasurementsRequest = binAPI.listBinMeasurements(bin.key(), startDate, endDate, 0, 0, 'asc');
const [cableResponses, fanResponses, heaterResponses, cableHistoryResponses, binMeasurementsResponse] = await Promise.all([
Promise.all(cableSampleRequests),
Promise.all(fanSampleRequests),
Promise.all(heaterSampleRequests),
Promise.all(cableHistoryRequests),
binMeasurementsRequest,
]);
// Build a per-cable sorted top-node history map for use during checkpoint construction.
const topNodeHistories = new Map<string, TopNodeHistoryEntry[]>();
bin.status.grainCables.forEach((cable, i) => {
const historyResp = cableHistoryResponses[i];
const entries = buildTopNodeHistory(historyResp?.data?.history ?? []);
topNodeHistories.set(cable.key, entries);
});
// Build sorted bushel history from bin object measurements.
const bushelHistory = buildBushelHistory(binMeasurementsResponse.data);
const checkpoints = buildStatusCheckpoints(
bin.status.grainCables,
bin.status.fans,
bin.status.heaters,
cableResponses.map(r => r.data),
fanResponses.map(r => r.data),
heaterResponses.map(r => r.data),
topNodeHistories,
bushelHistory,
bin.status.grainBushels,
startDate,
endDate,
checkpointCountFromRange(startDate, endDate)
);
for (let i = 0; i < checkpoints.length; i++) {
if (!isPlaying.current) {
setBin(originalBin.current!);
setCurrentCheckpointTime(undefined);
setIsPlayingState(false);
return;
}
currentCheckpointIndex.current = i;
totalCheckpoints.current = checkpoints.length;
checkpointStartTime.current = Date.now();
const newBin = cloneDeep(originalBin.current!);
newBin.status.grainCables = checkpoints[i].cables;
newBin.status.fans = checkpoints[i].fans;
newBin.status.heaters = checkpoints[i].heaters;
newBin.status.grainBushels = checkpoints[i].statusBushels;
setBin(newBin);
setCurrentCheckpointTime(checkpoints[i].time);
await new Promise(res => setTimeout(res, PLAYBACK_INTERVAL));
}
isPlaying.current = false;
setIsPlayingState(false);
setBin(originalBin.current!);
setCurrentCheckpointTime(undefined);
};
const datePickerDialog = () => {
return (
<ResponsiveDialog
open={dateRangeDialog}
onClose={() => setDateRangeDialog(false)}
aria-labelledby="date-range-dialog">
<DialogContent>
<TextField
fullWidth
margin="normal"
type="date"
label="Start Date"
value={tempStartDate.format("YYYY-MM-DD")}
onChange={e => {
setTempStartDate(moment(e.target.value));
}}
/>
<TextField
fullWidth
margin="normal"
type="date"
label="End Date"
value={tempEndDate.format("YYYY-MM-DD")}
onChange={e => {
setTempEndDate(moment(e.target.value));
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setDateRangeDialog(false)} color="primary">
Close
</Button>
<Button onClick={() => {
const now = moment();
setStartDate(tempStartDate.clone().set({
hour: now.hour(),
minute: now.minute(),
second: now.second()
}));
setEndDate(tempEndDate.clone().set({
hour: now.hour(),
minute: now.minute(),
second: now.second()
}));
setDateRangeDialog(false);
}} color="primary">
Submit
</Button>
</DialogActions>
</ResponsiveDialog>
);
};
const dateSelector = () => {
return (
<Box display="flex" flexDirection="row" justifyContent="space-between">
<FormControl fullWidth>
<Select
value={dateSelect}
sx={{
fontSize: 12,
borderRadius: 2,
border: "1px solid",
borderColor: grey[800],
'& .MuiSelect-select': {
py: 1.5,
},
'& .MuiOutlinedInput-notchedOutline': { border: 'none' },
'&:hover .MuiOutlinedInput-notchedOutline': { border: 'none' },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { border: 'none' },
}}
onChange={event => {
setDateSelect(event.target.value as number);
let currentDate = moment();
setEndDate(currentDate);
switch (event.target.value) {
case 0:
setStartDate(currentDate.clone().subtract(1, 'week'));
break;
case 1:
setStartDate(currentDate.clone().subtract(2, 'weeks'));
break;
case 2:
setStartDate(currentDate.clone().subtract(4, 'weeks'));
break;
}
}}
>
<MenuItem value={0}>1 Week</MenuItem>
<MenuItem value={1}>2 Weeks</MenuItem>
<MenuItem value={2}>4 Weeks</MenuItem>
<MenuItem value={3}>Custom</MenuItem>
</Select>
</FormControl>
{dateSelect === 3 && (
<Button onClick={() => setDateRangeDialog(true)} color="primary"><DateRange /></Button>
)}
</Box>
);
};
return (
<React.Fragment>
{datePickerDialog()}
<Box display="flex" flexDirection="row" justifyContent="space-between" width="100%" gap={2} alignContent={"center"} alignItems={"center"}>
{/* start/stop button */}
<Box>
{isPlayingState ? (
<IconButton sx={{ border: "1px solid", borderRadius: "50%", padding: 1, height: isMobile ? 40 : 60, width: isMobile ? 40 : 60 }} onClick={stopPlayer}><Stop fontSize="large" /></IconButton>
) : (
<IconButton sx={{ border: "1px solid", borderRadius: "50%", padding: 1, height: isMobile ? 40 : 60, width: isMobile ? 40 : 60 }} onClick={startPlayer}><PlayArrow fontSize="large" /></IconButton>
)}
</Box>
{/* progress bar and date display */}
<Box sx={{ flexGrow: 1 }}>
<Typography fontSize={isMobile ? 15 : 17} textAlign={"center"}>{currentCheckpointTime ? currentCheckpointTime.format("MMM D, YYYY h:mm A") : moment().format("MMM D, YYYY h:mm A")}</Typography>
<LinearProgress value={displayProgress} variant="determinate" color="inherit" />
<Box display="flex" flexDirection="row" justifyContent="space-between">
<Typography variant="caption">{startDate.format("MMM D, YYYY")}</Typography>
<Typography variant="caption">{endDate.format("MMM D, YYYY")}</Typography>
</Box>
</Box>
{/* date range selector */}
<Box>
{dateSelector()}
</Box>
</Box>
</React.Fragment>
);
}

View file

@ -615,7 +615,9 @@ export default function BinSVGV2(props: Props) {
//determine how high to draw the node on the cable
const nodeY = nodeSpacingY * (index + 1) + minNodeY;
//if we are using the auto top nodes only show the heatmap for nodes at or below the top node and it is not an ecluded node
//if we are using the auto top nodes only show the heatmap for nodes at or below the top node and it is not an excluded node
//NOTE it is nodeNumber - 1 because the excluded node array uses a 0 based count for the node number but the nodeNumber variable uses a 1 based count
//ie. the bottom node of the cable would be nodeNumber of 1 but in the array it would be 0
if (!cable.excludedNodes.includes(nodeNumber - 1)){
if (cable.topNode > 0) {
if (nodeNumber <= cable.topNode) {

View file

@ -4,7 +4,7 @@ import SearchSelect, { Option } from "common/SearchSelect";
import { useMobile } from "hooks";
import { pond } from "protobuf-ts/pond";
import React, { useEffect, useState } from "react";
import { getDistanceUnit } from "utils";
import { useGlobalState } from "providers";
interface Props {
optionsChanged: (binOptions: jsonBin[]) => void;
@ -13,6 +13,7 @@ interface Props {
export default function BinSelector(props: Props) {
const { optionsChanged, vertical } = props;
const [{user}] = useGlobalState();
const isMobile = useMobile();
const [manufacturerOptions, SetManufacturerOptions] = useState<Option[]>([]);
const [manufacturer, SetManufacturer] = useState<Option | null>(null);
@ -126,7 +127,7 @@ export default function BinSelector(props: Props) {
}}
label={
"Minimum Diameter " +
(getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "(ft)" : "(m)")
(user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "(ft)" : "(m)")
}
/>
</Grid>
@ -143,7 +144,7 @@ export default function BinSelector(props: Props) {
}}
label={
"Maximum Diameter " +
(getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "(ft)" : "(m)")
(user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "(ft)" : "(m)")
}
/>
</Grid>

104
src/bin/BinSensorCard.tsx Normal file
View file

@ -0,0 +1,104 @@
import { AccessTime, MoreVert } from "@mui/icons-material";
import { Avatar, Box, Card, IconButton, Table, TableBody, TableCell, TableHead, TableRow, Typography, useTheme } from "@mui/material";
import moment from "moment";
import React from "react";
interface SensorRow {
label: string;
data: string;
}
interface CableRow {
label: string;
min: string;
avg: string;
max: string;
}
interface Props {
name: string;
icon?: string;
tag: string;
lastReading: string;
onMenuClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
rows?: SensorRow[];
cableRows?: CableRow[];
/**
* the time in hours that a reading is considered 'good'
* defaults to 6
*/
staleLimit?: number;
}
export default function BinSensorCard(props: Props) {
const { name, icon, tag, lastReading, onMenuClick, rows, cableRows, staleLimit = 6 } = props
const theme = useTheme()
const isStale = moment().diff(moment(lastReading), "hours") > staleLimit
return (
<Card raised sx={{ height: "100%" }}>
<Box padding={1.5} height="100%" display="flex" flexDirection="column">
<Box display="flex" justifyContent="space-between" alignItems="center" marginBottom={1}>
<Box display="flex" alignItems="center" gap={1}>
{icon &&
<Avatar
variant="square"
src={icon}
alt={name + " icon"}
style={{ width: theme.spacing(3), height: theme.spacing(3) }}
/>
}
<Typography fontWeight={500}>{name}</Typography>
<Typography variant="caption" color="textSecondary">- {tag}</Typography>
</Box>
<IconButton size="small" onClick={onMenuClick}>
<MoreVert fontSize="small" />
</IconButton>
</Box>
<Table size="small">
{cableRows ? (
<>
<TableHead>
<TableRow>
<TableCell sx={{ color: "text.secondary", fontSize: 11, fontWeight: 500, border: 0, pb: 0.5, pl: 0 }} />
<TableCell align="right" sx={{ color: "text.secondary", fontSize: 11, fontWeight: 500, border: 0, pb: 0.5 }}>Min</TableCell>
<TableCell align="right" sx={{ color: "primary.main", fontSize: 11, fontWeight: 500, border: 0, pb: 0.5 }}>Avg</TableCell>
<TableCell align="right" sx={{ color: "text.secondary", fontSize: 11, fontWeight: 500, border: 0, pb: 0.5, pr: 0 }}>Max</TableCell>
</TableRow>
</TableHead>
<TableBody>
{cableRows.map((row) => (
<TableRow key={row.label}>
<TableCell sx={{ color: "text.secondary", fontSize: 11, pl: 0, py: 0.5 }}>{row.label}</TableCell>
<TableCell align="right" sx={{ fontSize: 12, py: 0.5 }}>{row.min}</TableCell>
<TableCell align="right" sx={{ color: "primary.main", fontWeight: 500, fontSize: 12, py: 0.5 }}>{row.avg}</TableCell>
<TableCell align="right" sx={{ fontSize: 12, py: 0.5, pr: 0 }}>{row.max}</TableCell>
</TableRow>
))}
</TableBody>
</>
) : (
<TableBody>
{rows?.map((row) => (
<TableRow key={row.label}>
<TableCell sx={{ color: "text.secondary", fontSize: 11, pl: 0, py: 0.5 }}>{row.label}</TableCell>
<TableCell align="right" sx={{ fontSize: 12, py: 0.5 }}>{row.data}</TableCell>
</TableRow>
))}
</TableBody>
)}
</Table>
<Box display="flex" alignItems="center" gap={0.5} marginTop="auto">
<AccessTime sx={{ fontSize: 12, color: isStale ? "warning.main" : "text.disabled" }} />
<Typography variant="caption" color={isStale ? "warning.main" : "text.secondary"}>
{moment(lastReading).fromNow()}
</Typography>
</Box>
</Box>
</Card>
)
}

View file

@ -30,7 +30,9 @@ interface Props {
coords?: { longitude: number; latitude: number };
binYards?: pond.BinYardSettings[];
components?: Map<string, Component>;
componentDevices?: Map<string, number>
setComponents?: React.Dispatch<React.SetStateAction<Map<string, Component>>>;
setComponentDevices?: React.Dispatch<React.SetStateAction<Map<string, number>>>;
updateBinStatus?: (componentKeys: string[], removed?: boolean) => void;
}
@ -43,7 +45,9 @@ export default function BinSensors(props: Props) {
mode,
openedBinYard,
components,
componentDevices,
setComponents,
setComponentDevices,
updateBinStatus
} = props;
const [initialized, setInitialized] = useState(false);
@ -93,7 +97,9 @@ export default function BinSensors(props: Props) {
<Box padding={1}>
<BinComponents
components={components ? components : undefined}
componentDevices={componentDevices}
setComponents={setComponents}
setComponentDevices={setComponentDevices}
bin={bin.key()}
binGrain={bin.grain()}
updateBinStatus={updateBinStatus}

View file

@ -61,7 +61,7 @@ import { pond } from "protobuf-ts/pond";
import { useBinAPI, useBinYardAPI, useGlobalState, useLibraCartProxyAPI } from "providers";
import React, { useCallback, useEffect, useState } from "react";
// import { useHistory } from "react-router";
import { getDistanceUnit, or, getTemperatureUnit, getGrainUnit } from "utils";
import { or } from "utils";
import { makeStyles } from "@mui/styles";
import { useNavigate } from "react-router-dom";
import BinSelector from "./BinSelector";
@ -240,16 +240,16 @@ export default function BinSettings(props: Props) {
initForm.inventory.bushelsPerTonne = grain.bushelsPerTonne;
}
if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE){
if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE){
setBushelConversion(initForm.inventory.bushelsPerTonne)
}else if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON){
}else if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON){
setBushelConversion(initForm.inventory.bushelsPerTonne * 0.907)
}
if (initForm.inventory.customGrain){
setCustomGrain(initForm.inventory.customGrain)
if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE){
if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE){
setBushelConversion(initForm.inventory.customGrain.bushelsPerTonne)
}else if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON){
}else if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON){
setBushelConversion(initForm.inventory.customGrain.bushelsPerTonne * 0.907)
}
}
@ -269,7 +269,7 @@ export default function BinSettings(props: Props) {
let sidewallHeight = "";
let hopperHeight = "";
if (initForm.specs) {
if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) {
if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) {
h = (initForm.specs.heightCm * 0.0328).toFixed(2);
d = (initForm.specs.diameterCm * 0.0328).toFixed(2);
if (initForm.specs.advancedDimensions) {
@ -290,7 +290,7 @@ export default function BinSettings(props: Props) {
let high = initForm.highTemp ?? 20;
let low = initForm.lowTemp ?? 10;
let target = initForm.inventory?.targetTemperature ?? 15;
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
high = parseFloat((high * 1.8 + 32).toFixed(2));
low = parseFloat((low * 1.8 + 32).toFixed(2));
target = parseFloat((target * 1.8 + 32).toFixed(2));
@ -304,15 +304,15 @@ export default function BinSettings(props: Props) {
: initForm.inventory?.grainBushels ?? 0;
let weight = ""
if(initForm.inventory){
if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE){
if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE){
weight = (gb/initForm.inventory.bushelsPerTonne).toFixed(2)
}else if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON){
}else if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON){
weight = (gb/(initForm.inventory.bushelsPerTonne * 0.907)).toFixed(2)
}
if(initForm.inventory.customGrain){
if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE){
if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE){
weight = (gb/initForm.inventory.customGrain.bushelsPerTonne).toFixed(2)
}else if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON){
}else if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON){
weight = (gb/(initForm.inventory.customGrain.bushelsPerTonne * 0.907)).toFixed(2)
}
}
@ -350,7 +350,7 @@ export default function BinSettings(props: Props) {
setMoistureTargetDeviation(initForm.inventory?.moistureTargetDeviation.toString() ?? "0");
setAutoFillThreshold(initForm.inventory?.autoThreshold ?? 5)
let dropDistance = initForm.inventory?.lidarDropCm ?? 0
if(getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET){
if(user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET){
dropDistance = Math.round((dropDistance/30.48)*100)/100
}
setLidarDropDistance(dropDistance)
@ -364,7 +364,7 @@ export default function BinSettings(props: Props) {
} else {
setInitialized(false);
}
}, [bin, open, mode, openedBinYard]);
}, [bin, open, mode, openedBinYard, user]);
useEffect(() => {
if (mode === "remove") {
@ -475,7 +475,7 @@ export default function BinSettings(props: Props) {
if (form.inventory) {
form.inventory.inventoryControl = inventoryControl
form.inventory.autoThreshold = autoFillThreshold
form.inventory.lidarDropCm = getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? lidarDropDistance * 30.48 : lidarDropDistance
form.inventory.lidarDropCm = user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? lidarDropDistance * 30.48 : lidarDropDistance
if(form.inventory.grainType !== pond.Grain.GRAIN_CUSTOM){
form.inventory.customGrain = undefined
}else{
@ -529,7 +529,7 @@ export default function BinSettings(props: Props) {
form.inventory.inventoryControl = inventoryControl
form.inventory.autoThreshold = autoFillThreshold
//if the users preferences are in feet convert the distance to cm otherwise it was entered as cm so use that
form.inventory.lidarDropCm = getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? lidarDropDistance * 30.48 : lidarDropDistance
form.inventory.lidarDropCm = user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? lidarDropDistance * 30.48 : lidarDropDistance
if(form.inventory.grainType !== pond.Grain.GRAIN_CUSTOM){
form.inventory.customGrain = undefined
}else{
@ -804,7 +804,7 @@ export default function BinSettings(props: Props) {
const binQuantity = formExtension.grainBushels;
//as long as the storage type is not fertilizer it is some sort of grain, whether it is supported or custom is not important in this instance so we do not need to check for unknown
if(storageType !== pond.BinStorage.BIN_STORAGE_FERTILIZER && ((getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE || getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON) && bushelConversion !== 0 && grainWeight !== "")){
if(storageType !== pond.BinStorage.BIN_STORAGE_FERTILIZER && ((user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE || user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON) && bushelConversion !== 0 && grainWeight !== "")){
return (
<TextField
label="Amount"
@ -830,7 +830,7 @@ export default function BinSettings(props: Props) {
variant="outlined"
disabled={!canEdit}
InputProps={{
endAdornment: <InputAdornment position="end">{getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE ? "mT" : "t"}</InputAdornment>
endAdornment: <InputAdornment position="end">{user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE ? "mT" : "t"}</InputAdornment>
}}
className={classes.bottomSpacing}
/>
@ -1099,7 +1099,7 @@ export default function BinSettings(props: Props) {
InputProps={{
endAdornment: (
<InputAdornment position="end">
{getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "ft" : "cm"}
{user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "ft" : "cm"}
</InputAdornment>
)
}}
@ -1179,9 +1179,9 @@ export default function BinSettings(props: Props) {
setCustomGrain(newGrainSettings)
let conversion = 0
if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE){
if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE){
conversion = newGrainSettings.bushelsPerTonne
}else if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON){
}else if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON){
conversion = newGrainSettings.bushelsPerTonne * 0.907
}
updateForm(
@ -1218,9 +1218,9 @@ export default function BinSettings(props: Props) {
})
);
let conversion = 0
if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE){
if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE){
conversion = GrainDescriber(newGrainType).bushelsPerTonne
}else if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON){
}else if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON){
conversion = GrainDescriber(newGrainType).bushelsPerTon
}
setBushelConversion(conversion)
@ -1435,7 +1435,7 @@ export default function BinSettings(props: Props) {
let sH = sidewallHeight;
let hH = hopperHeight ?? 0;
let d = diameter;
if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) {
if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) {
tcH = tcH * 3.281;
sH = sH * 3.281;
hH = hH * 3.281;
@ -1463,7 +1463,7 @@ export default function BinSettings(props: Props) {
let hopperHeight = formExtension.hopperHeight;
let diameter = formExtension.diameter;
// if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) {
// if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) {
// diameterM = (Number(diameterM) * 3.28084).toFixed(2);
// heightM = (Number(heightM) * 3.28084).toFixed(2);
// }
@ -1552,7 +1552,7 @@ export default function BinSettings(props: Props) {
let d = diameter;
let valueM = value;
let dM = diameter;
if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) {
if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) {
valueM = (Number(value) * 0.3048).toFixed(2);
dM = (Number(d) * 0.3048).toFixed(2);
}
@ -1577,7 +1577,7 @@ export default function BinSettings(props: Props) {
InputProps={{
endAdornment: (
<InputAdornment position="end">
{getDistanceUnit() !== pond.DistanceUnit.DISTANCE_UNIT_FEET ? "m" : "ft"}
{user.distanceUnit() !== pond.DistanceUnit.DISTANCE_UNIT_FEET ? "m" : "ft"}
</InputAdornment>
)
}}
@ -1637,7 +1637,7 @@ export default function BinSettings(props: Props) {
let value = event?.target.value;
let valueM = value;
setModelID(0);
if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) {
if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) {
valueM = (Number(value) * 0.3048).toFixed(2);
}
let coneCM =
@ -1674,7 +1674,7 @@ export default function BinSettings(props: Props) {
//need to get the total height in cm
let totalHeightCM = s + t + h; //the total height in the users units
if(getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET){
if(user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET){
totalHeightCM = totalHeightCM * 30.48 //convert from feet to cm
}else{
totalHeightCM = totalHeightCM * 100 //convert from m to cm
@ -1698,7 +1698,7 @@ export default function BinSettings(props: Props) {
InputProps={{
endAdornment: (
<InputAdornment position="end">
{getDistanceUnit() !== pond.DistanceUnit.DISTANCE_UNIT_FEET
{user.distanceUnit() !== pond.DistanceUnit.DISTANCE_UNIT_FEET
? "m"
: "ft"}
</InputAdornment>
@ -1725,7 +1725,7 @@ export default function BinSettings(props: Props) {
//calculate the new cone height for the roof angle
let coneCM = TriangleOppositeLength(d / 2, angle ?? 0);
let newConeHeight =
getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET
user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET
? (coneCM / 30.48).toFixed(2)
: (coneCM / 100).toFixed(2);
@ -1749,7 +1749,7 @@ export default function BinSettings(props: Props) {
//need to get the total height in cm
let totalHeightCM = s + t + h; //the total height in the users units
if(getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET){
if(user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET){
totalHeightCM = totalHeightCM * 30.48 //convert from feet to cm
}else{
totalHeightCM = totalHeightCM * 100 //convert from m to cm
@ -1786,7 +1786,7 @@ export default function BinSettings(props: Props) {
onChange={event => {
let value = event?.target.value;
let valueM = value;
if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) {
if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) {
valueM = (Number(value) * 0.3048).toFixed(2);
}
let ext = cloneDeep(formExtension);
@ -1811,7 +1811,7 @@ export default function BinSettings(props: Props) {
//need to get the total height in cm
let totalHeightCM = s + t + h; //the total height in the users units
if(getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET){
if(user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET){
totalHeightCM = totalHeightCM * 30.48 //convert from feet to cm
}else{
totalHeightCM = totalHeightCM * 100 //convert from m to cm
@ -1838,7 +1838,7 @@ export default function BinSettings(props: Props) {
InputProps={{
endAdornment: (
<InputAdornment position="end">
{getDistanceUnit() !== pond.DistanceUnit.DISTANCE_UNIT_FEET
{user.distanceUnit() !== pond.DistanceUnit.DISTANCE_UNIT_FEET
? "m"
: "ft"}
</InputAdornment>
@ -1868,7 +1868,7 @@ export default function BinSettings(props: Props) {
let coneCM = TriangleOppositeLength(d / 2, angle ?? 0);
let newHopperHeight =
getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET
user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET
? (coneCM / 30.48).toFixed(2)
: (coneCM / 100).toFixed(2);
@ -1926,7 +1926,7 @@ export default function BinSettings(props: Props) {
onChange={event => {
let value = event?.target.value;
let valueM = value;
if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) {
if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) {
valueM = (Number(value) * 0.3048).toFixed(2);
}
@ -1982,7 +1982,7 @@ export default function BinSettings(props: Props) {
InputProps={{
endAdornment: (
<InputAdornment position="end">
{getDistanceUnit() !== pond.DistanceUnit.DISTANCE_UNIT_FEET
{user.distanceUnit() !== pond.DistanceUnit.DISTANCE_UNIT_FEET
? "m"
: "ft"}
</InputAdornment>
@ -2009,7 +2009,7 @@ export default function BinSettings(props: Props) {
let h = height;
let valueM = value;
let hM = height;
if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) {
if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) {
valueM = (Number(value) * 0.3048).toFixed(2);
hM = (Number(h) * 0.3048).toFixed(2);
}
@ -2063,7 +2063,7 @@ export default function BinSettings(props: Props) {
InputProps={{
endAdornment: (
<InputAdornment position="end">
{getDistanceUnit() !== pond.DistanceUnit.DISTANCE_UNIT_FEET ? "m" : "ft"}
{user.distanceUnit() !== pond.DistanceUnit.DISTANCE_UNIT_FEET ? "m" : "ft"}
</InputAdornment>
)
}}
@ -2253,7 +2253,7 @@ export default function BinSettings(props: Props) {
let userHopperHeight = hopperHeight;
let userDiameter = jsonBin.Diameter;
//since the jsone data is in feet just check if we need to make it in meters
if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) {
if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) {
userHeight = userHeight / 3.281;
userDiameter = userDiameter / 3.281;
userTopCone = userTopCone / 3.281;
@ -2308,7 +2308,7 @@ export default function BinSettings(props: Props) {
onChange={event => {
let value = event?.target.value;
let valueC = value;
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
valueC = ((Number(value) - 32) * (5 / 9)).toFixed(2);
}
@ -2320,7 +2320,7 @@ export default function BinSettings(props: Props) {
InputProps={{
endAdornment: (
<InputAdornment position="end">
{getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
{user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
? "F"
: "C"}
</InputAdornment>
@ -2348,7 +2348,7 @@ export default function BinSettings(props: Props) {
onChange={event => {
let value = event?.target.value;
let valueC = value;
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
valueC = ((Number(value) - 32) * (5 / 9)).toFixed(2);
}
setLowTempC(+valueC);
@ -2357,7 +2357,7 @@ export default function BinSettings(props: Props) {
InputProps={{
endAdornment: (
<InputAdornment position="end">
{getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
{user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
? "F"
: "C"}
</InputAdornment>
@ -2383,7 +2383,7 @@ export default function BinSettings(props: Props) {
onChange={event => {
let value = event?.target.value;
let valueC = value;
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
valueC = ((Number(value) - 32) * (5 / 9)).toFixed(2);
}
setHighTempC(+valueC);
@ -2392,7 +2392,7 @@ export default function BinSettings(props: Props) {
InputProps={{
endAdornment: (
<InputAdornment position="end">
{getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
{user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
? "F"
: "C"}
</InputAdornment>

View file

@ -24,7 +24,7 @@ import Co2Icon from "products/CommonIcons/co2Icon";
import { pond, quack } from "protobuf-ts/pond";
import { useBinAPI, useGlobalState, useSnackbar } from "providers";
import React, { useEffect, useState } from "react";
import { avg, getTemperatureUnit } from "utils";
import { avg } from "utils";
import { makeStyles } from "@mui/styles";
import { Mark } from "@mui/material/Slider/useSlider.types";
import { CO2 } from "models/CO2";
@ -134,7 +134,7 @@ export default function BinStorageConditions(props: Props) {
const binAPI = useBinAPI();
const { openSnack } = useSnackbar();
const { bin, headspaceCO2, cables } = props;
const [{as}] = useGlobalState()
const [{as, user}] = useGlobalState()
const [sliderTemps, setSliderTemps] = useState<number[]>([-40, 40]);
//boolean that if a cable is missing its filled to display an icon or something to let the user know not all of the cables have their fill set
const [missingTopNodeWarning, setMissingTopNodeWarning] = useState(false);
@ -233,7 +233,7 @@ export default function BinStorageConditions(props: Props) {
}
setTempTargets(tempCounts);
let tempVal = bin.settings.inventory?.targetTemperature ?? 0;
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
tempVal = tempVal * 1.8 + 32;
}
setTargetTemp(tempVal.toFixed(1));
@ -244,7 +244,7 @@ export default function BinStorageConditions(props: Props) {
setEmcTargets(emcCounts);
setTargetEMC(bin.settings.inventory?.targetMoisture.toFixed(1) ?? "");
setEmcDeviation(bin.settings.inventory?.moistureTargetDeviation.toFixed(1) ?? "");
}, [bin, cables]);
}, [bin, cables, user]);
//useEffect that watches for changes in the target emc and deviation to update the targets
useEffect(() => {
@ -301,7 +301,7 @@ export default function BinStorageConditions(props: Props) {
settings.highTemp = sliderTemps[1];
if (settings.inventory) {
let tempVal = parseFloat(targetTemp);
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
tempVal = Math.fround(((tempVal - 32) * 5) / 9);
}
settings.inventory.targetTemperature = tempVal;
@ -376,14 +376,14 @@ export default function BinStorageConditions(props: Props) {
let mark: Mark[] = [];
if (averageTemp) {
let temp = averageTemp;
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
temp = temp * 1.8 + 32;
}
mark = [
{
label: customMark(
temp.toFixed(1) +
(getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
(user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
? "°F"
: "°C"),
"AVG"
@ -422,7 +422,7 @@ export default function BinStorageConditions(props: Props) {
InputProps={{
endAdornment: (
<InputAdornment position="end">
{getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
{user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
? "°F"
: "°C"}
</InputAdornment>
@ -451,7 +451,7 @@ export default function BinStorageConditions(props: Props) {
max={sliderEdge}
valueLabelDisplay="on"
valueLabelFormat={value => {
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
return ((value * 9) / 5 + 32).toFixed(1) + "°F";
}
return value.toFixed(1) + "°C";

View file

@ -0,0 +1,194 @@
import { Box, Table, TableBody, TableCell, TableHead, TableRow, Theme, Typography, useTheme } from "@mui/material";
import { pond } from "protobuf-ts/pond";
import { makeStyles } from "@mui/styles";
import { useEffect, useMemo, useState } from "react";
import { avg, celsiusToFahrenheit } from "utils";
import { useGlobalState } from "providers";
import { darken } from "@mui/material";
import React from "react";
const useStyles = makeStyles((theme: Theme) => ({
tableStyle: {
borderCollapse: "separate",
borderSpacing: 0,
},
headerCellStyle: {
fontWeight: 650,
backgroundColor: darken(theme.palette.background.paper, 0.3),
fontSize: 15,
textAlign: "center",
borderBottom: "none",
padding: 10,
},
defaultCellStyle: {
padding: 2,
fontWeight: 650,
fontSize: 17,
textAlign: "center",
backgroundColor: darken(theme.palette.background.paper, 0.1),
borderTop: "1px solid " + darken(theme.palette.background.paper, 0.4),
borderBottom: "none",
},
colouredCellStyle: {
padding: 2,
fontWeight: 650,
fontSize: 17,
textAlign: "center",
borderTop: "1px solid " + darken(theme.palette.background.paper, 0.4),
borderBottom: "none",
},
}));
// interface Column {
// label: string // e.g. "Cable 1 Temp", "Cable 2 Moisture"
// cableIndex: number
// type: "temp" | "moisture"
// }
interface Props {
cables: pond.GrainCable[]
targetTemp: number // in Celsius, for colouring
targetMoisture: number // for colouring
inBounds?: string
caution?: string
warning?: string
}
export default function BinTableExpanded(props: Props) {
const { cables, targetTemp, targetMoisture, inBounds = "#43a047", caution = "#f9a825", warning = "#c62828" } = props
const classes = useStyles()
const [{ user }] = useGlobalState()
const theme = useTheme()
const useFahrenheit = user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
// Row count driven by the longest cable
const rowCount = useMemo(
() => Math.max(0, ...cables.map(c => c.celcius.length)),
[cables]
)
// Render a single cell value + background colour
const renderCell = (cable: pond.GrainCable, nodeIndex: number, type: "temp" | "moisture") => {
if (type === "temp") {
const raw = cable.celcius[nodeIndex]
if (raw === undefined) return { display: "--", color: "" }
const isExcluded = cable.excludedNodes.includes(nodeIndex)
if (isExcluded) return { display: "--", color: "" }
let display: string
let color = ""
const inGrain = nodeIndex < cable.topNode
if (inGrain) {
if (raw > targetTemp + 10) color = warning
else if (raw > targetTemp + 5) color = caution
else color = inBounds
}
const converted = useFahrenheit ? celsiusToFahrenheit(raw) : raw
display = converted.toFixed(1) + (useFahrenheit ? " °F" : " °C")
return { display, color }
}
// moisture
const raw = cable.moisture[nodeIndex]
if (raw === undefined || raw === 0) return { display: "--", color: "" }
const isExcluded = cable.excludedNodes.includes(nodeIndex)
if (isExcluded) return { display: "--", color: "" }
let color = ""
const inGrain = nodeIndex < cable.topNode
if (inGrain) {
if (raw > targetMoisture + 1.5) color = warning
else if (raw > targetMoisture + 0.5) color = caution
else color = inBounds
}
return { display: raw.toFixed(2) + "%", color }
}
const cableTable = (cable: pond.GrainCable) => {
return (
<Table className={classes.tableStyle}>
<TableHead>
<TableRow>
<TableCell className={classes.headerCellStyle} sx={{ borderTopLeftRadius: 8 }}>
Level
</TableCell>
<TableCell className={classes.headerCellStyle}>
Temperature
</TableCell>
<TableCell className={classes.headerCellStyle} sx={{ borderTopRightRadius: 8 }}>
Moisture
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Array.from({ length: rowCount }, (_, i) => rowCount - 1 - i).map((nodeIndex, rowI) => {
const isLast = rowI === rowCount - 1
const tempData = renderCell(cable, nodeIndex, "temp")
const moistureData = renderCell(cable, nodeIndex, "moisture")
return (
<TableRow key={nodeIndex}>
<TableCell
padding="none"
className={classes.defaultCellStyle}
sx={{ borderBottomLeftRadius: isLast ? 8 : 0 }}>
{nodeIndex + 1}
</TableCell>
<TableCell
padding="none"
className={classes.colouredCellStyle}
sx={{ backgroundColor: tempData.color || darken(theme.palette.background.paper, 0.1) }}>
{tempData.display}
</TableCell>
<TableCell
padding="none"
className={classes.colouredCellStyle}
sx={{
backgroundColor: moistureData.color || darken(theme.palette.background.paper, 0.1),
borderBottomRightRadius: isLast ? 8 : 0,
}}>
{moistureData.display}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)
}
return (
<Box padding={1}>
{/* Table Legend */}
<Box display="flex" alignItems="center" justifyContent={"space-between"} marginY={2}>
<Box display="flex" alignItems="center" gap={1}>
<Box sx={{ width: 40, height: 20, backgroundColor: inBounds, borderRadius: 1, flexShrink: 0 }} />
<Typography sx={{fontWeight: 600, fontSize: 10}}>On target</Typography>
</Box>
<Box display="flex" alignItems="center" gap={1}>
<Box sx={{ width: 40, height: 20, backgroundColor: caution, borderRadius: 1, flexShrink: 0 }} />
<Typography sx={{fontWeight: 600, fontSize: 10}}>{user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? "~10 °F" : "+5 °C"} above</Typography>
</Box>
<Box display="flex" alignItems="center" gap={1}>
<Box sx={{ width: 40, height: 20, backgroundColor: warning, borderRadius: 1, flexShrink: 0 }} />
<Typography sx={{fontWeight: 600, fontSize: 10}}>{user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? "~20 °F" : "+10 °C"} above</Typography>
</Box>
</Box>
<Box sx={{ display: "flex", flexDirection: "row", overflowX: "auto", gap: 2, paddingBottom: 2}}>
{cables.map((cable, i) => (
<Box key={i} sx={{ flexShrink: 0 }}>
<Typography className={classes.headerCellStyle} sx={{ textAlign: "center" }}>
{cable.name}
</Typography>
{cableTable(cable)}
</Box>
))}
</Box>
</Box>
)
}

View file

@ -10,11 +10,10 @@ import { Transaction } from "models/Transaction";
import moment from "moment";
import ObjectDescriber from "objects/ObjectDescriber";
import { pond } from "protobuf-ts/pond";
import { useBinAPI, useContractAPI, useFieldAPI, useGrainBagAPI, useSnackbar, useTransactionAPI } from "providers";
import { useBinAPI, useContractAPI, useFieldAPI, useGlobalState, useGrainBagAPI, useSnackbar, useTransactionAPI } from "providers";
import React from "react";
import { useCallback, useEffect, useState } from "react";
import TransactionDataDisplay from "transactions/transactionDataDisplay";
import { getGrainUnit } from "utils";
interface Props {
bin: Bin
@ -26,6 +25,7 @@ export default function BinTransactions(props: Props){
const { bin, permissions, refresh } = props
const [state, setState] = useState<pond.TransactionState>(pond.TransactionState.TRANSACTION_STATE_APPROVAL_REQUIRED)
const transactionAPI = useTransactionAPI()
const [{user}] = useGlobalState()
const binAPI = useBinAPI()
const grainBagAPI = useGrainBagAPI()
const fieldAPI = useFieldAPI()
@ -103,7 +103,7 @@ export default function BinTransactions(props: Props){
const loadContracts = useCallback(()=>{
contractAPI.listContracts(0,0, "asc", "name").then(resp => {
setContractOptions(resp.data.contracts.map(c => {
let contract = Contract.create(c)
let contract = Contract.create(c, user)
return {label: contract.name(), value: contract.key(), group: contract.grainName()}
}))
})
@ -198,11 +198,11 @@ export default function BinTransactions(props: Props){
const grainQuantityDisplay = (bushels: number) => {
const grainTransaction = selectedTransaction?.transaction.transaction?.grainTransaction ?? pond.GrainTransaction.create()
if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && grainTransaction.bushelsPerTonne > 1){
if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && grainTransaction.bushelsPerTonne > 1){
let tonneWeight = bushels / grainTransaction.bushelsPerTonne
return tonneWeight + " mT"
}
if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && grainTransaction.bushelsPerTonne > 1){
if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && grainTransaction.bushelsPerTonne > 1){
let tonneWeight = bushels / (grainTransaction.bushelsPerTonne * 0.907)
return tonneWeight + " t"
}

View file

@ -36,11 +36,10 @@ import { pond } from "protobuf-ts/pond";
import { quack } from "protobuf-ts/quack";
import { useGlobalState, useSnackbar } from "providers";
import React, { useCallback, useEffect, useState } from "react";
import { FullScreen, useFullScreenHandle } from "react-full-screen";
import moment, { Moment } from "moment";
import ResponsiveDialog from "common/ResponsiveDialog";
import { getThemeType } from "theme";
import { getGrainUnit, getTemperatureUnit, or } from "utils";
import { or } from "utils";
import { useBinAPI } from "providers/pond/binAPI";
import BindaptIcon from "products/Bindapt/BindaptIcon";
import {
@ -72,6 +71,7 @@ import ButtonGroup from "common/ButtonGroup";
import ModeChangeDialog from "./conditioning/modeChangeDialog";
import CustomGrainSelector from "grain/CustomGrainSelector";
import BinControllerDisplay from "./BinControllerDisplay";
import { useFullScreen } from "hooks/FullScreenHandle";
const useStyles = makeStyles((theme: Theme) => {
return ({
@ -217,7 +217,7 @@ export default function BinVisualizer(props: Props) {
const isMobile = useMobile();
const classes = useStyles();
const theme = useTheme();
const fullScreenHandler = useFullScreenHandle();
const {fullScreenHandler, FullScreenWrapper} = useFullScreen()
const viewport = useViewport();
const { openSnack } = useSnackbar();
const [fillPercentage, setFillPercentage] = useState<number | null>(0);
@ -498,9 +498,9 @@ export default function BinVisualizer(props: Props) {
if (bin.storage() === pond.BinStorage.BIN_STORAGE_FERTILIZER) {
return Math.round(val * 35.239 * 100) / 100;
} else {
if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && bin.bushelsPerTonne() > 1) {
if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && bin.bushelsPerTonne() > 1) {
return Math.round((val / bin.bushelsPerTonne()) * 100) / 100;
} else if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && bin.bushelsPerTonne() > 1) {
} else if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && bin.bushelsPerTonne() > 1) {
return Math.round((val / (bin.bushelsPerTonne()*0.907)) * 100) / 100;
}
}
@ -516,9 +516,9 @@ export default function BinVisualizer(props: Props) {
if (bin.storage() === pond.BinStorage.BIN_STORAGE_FERTILIZER) {
return " / " + capacity.toLocaleString() + " L";
}
if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && bin.bushelsPerTonne() > 1) {
if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && bin.bushelsPerTonne() > 1) {
return "mT (" + bin.fillPercent() + "%)";
} else if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && bin.bushelsPerTonne() > 1) {
} else if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && bin.bushelsPerTonne() > 1) {
return "t (" + bin.fillPercent() + "%)";
} else {
return " / " + capacity.toLocaleString() + " bu";
@ -605,7 +605,7 @@ export default function BinVisualizer(props: Props) {
break;
}
if (valC) {
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
temp = CtoF(valC).toFixed(1) + "°F";
} else {
temp = valC.toFixed(1) + "°C";
@ -705,7 +705,7 @@ export default function BinVisualizer(props: Props) {
break;
}
if (valC) {
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
temp = Math.abs(valC * 1.8).toFixed(1) + "°F"; //since this is a measurement of change a change in 1 degrre celsius is equivalent to 1.8 fahrenheit
} else {
temp = Math.abs(valC).toFixed(1) + "°C";
@ -1159,7 +1159,7 @@ export default function BinVisualizer(props: Props) {
<BindaptIcon />
</Avatar>
<Box textAlign="center" width={"85%"}>
<Link href="https://www.bindapt.com/bins/">View Smart Bin Devices</Link>
<Typography>No Plenums found</Typography>
</Box>
</Box>
)}
@ -1328,14 +1328,14 @@ export default function BinVisualizer(props: Props) {
}
}
if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && bin.bushelsPerTonne() > 1) {
if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && bin.bushelsPerTonne() > 1) {
diffDisplay = diffDisplay / bin.bushelsPerTonne();
if (pendingDisplay) {
pendingDisplay = pendingDisplay / bin.bushelsPerTonne();
}
}
if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && bin.bushelsPerTonne() > 1) {
if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && bin.bushelsPerTonne() > 1) {
diffDisplay = diffDisplay / (bin.bushelsPerTonne()*0.907);
if (pendingDisplay) {
pendingDisplay = pendingDisplay / (bin.bushelsPerTonne()*0.907);
@ -1344,7 +1344,7 @@ export default function BinVisualizer(props: Props) {
return (
<Box display="flex" width={1} justifyContent="flex-end">
<FullScreen handle={fullScreenHandler}>
<FullScreenWrapper>
<Box
position="relative"
height={1}
@ -1441,7 +1441,7 @@ export default function BinVisualizer(props: Props) {
/>
</Box>
</Box>
</FullScreen>
</FullScreenWrapper>
</Box>
);
};
@ -1539,7 +1539,7 @@ export default function BinVisualizer(props: Props) {
<InfoOutlined />
</IconButton>
) : (
<IconButton size="small" onClick={fullScreenHandler.enter}>
<IconButton size="small" onClick={() => fullScreenHandler.enter()}>
<FullscreenIcon />
</IconButton>
)}
@ -1676,7 +1676,7 @@ export default function BinVisualizer(props: Props) {
fontWeight: 650,
color: tempColour
}}>
{ambient?.getTempString(getTemperatureUnit())}
{ambient?.getTempString(user.tempUnit())}
</Typography>
</Box>
<Box display="flex" marginY={1}>

View file

@ -35,7 +35,7 @@ import { Bin } from "models";
import { pond } from "protobuf-ts/pond";
import { useBinAPI, useGlobalState } from "providers";
import React, { useEffect, useState, useCallback, SetStateAction } from "react";
import { getGrainUnit, stringToMaterialColour } from "utils";
import { stringToMaterialColour } from "utils";
//import BinsFansStatusTable from "./BinFansStatusTable";
import BinsList from "./BinsList";
@ -95,7 +95,7 @@ export default function BinyardDisplay(props: Props) {
const isMobile = useMobile();
const [carouselIndex, setCarouselIndex] = useState(0);
const [binMenuAnchorEl, setBinMenuAnchorEl] = useState<Element | null>(null);
const [{ as }] = useGlobalState();
const [{ as, user }] = useGlobalState();
const maxBins = 40;
const [binsLoading, setBinsLoading] = useState(false);
const [expandTotal, setExpandTotal] = useState(false);
@ -658,7 +658,7 @@ export default function BinyardDisplay(props: Props) {
return " bu";
}
switch (getGrainUnit()) {
switch (user.grainUnit()) {
case pond.GrainUnit.GRAIN_UNIT_TONNE:
return " mT";
case pond.GrainUnit.GRAIN_UNIT_TON:
@ -671,9 +671,9 @@ export default function BinyardDisplay(props: Props) {
const customQuantityDisplay = (customInventory: pond.CustomInventory, bushels: number) => {
let amount = bushels
if(customInventory.bushelsPerTonne > 1){
if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE){
if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE){
amount = bushels/customInventory.bushelsPerTonne
}else if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON){
}else if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON){
amount = bushels/(customInventory.bushelsPerTonne*0.907)
}
}
@ -683,9 +683,9 @@ export default function BinyardDisplay(props: Props) {
const supportedGrainDisplay = (grain: pond.Grain, bushels: number) => {
const describer = GrainDescriber(grain)
let amount = bushels
if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE){
if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE){
amount = bushels/describer.bushelsPerTonne
}else if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON){
}else if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON){
amount = bushels/describer.bushelsPerTon
}
return Math.round(amount*100)/100

View file

@ -87,9 +87,24 @@ export default function GrainNodeInteractions(props: Props) {
const [nodeHum, setNodeHum] = useState<number>();
const [nodeMoist, setNodeMoist] = useState<number | undefined>();
const [{ user, as }] = useGlobalState();
const tempDescriber = describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE);
const humDescriber = describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT);
const moistureDescriber = describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC);
const tempDescriber = describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE,
undefined,
undefined,
user);
const humDescriber = describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_PERCENT,
quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE,
undefined,
undefined,
user);
const moistureDescriber = describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC,
quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE,
undefined,
undefined,
user);
const [topNode, setTopNode] = useState(false);
const [excluded, setExcluded] = useState(false);
const componentAPI = useComponentAPI();

423
src/bin/bin3dVisualizer.tsx Normal file
View file

@ -0,0 +1,423 @@
import { Box, Checkbox, FormControl, MenuItem, List, ListItem, InputLabel, Select, Typography, Stack, FormControlLabel, useTheme } from "@mui/material";
import Bin3dView from "bin/3dView/Scene/Bin3dView";
import { Bin, Component, Device } from "models";
import { Controller } from "models/Controller";
import { pond, quack } from "protobuf-ts/pond";
import React, { useEffect } from "react";
import { useState } from "react";
import NodeControls from "./binSummary/components/nodeControls";
import { NodeData } from "bin/3dView/Data/BuildNodeData";
import { CableData } from "bin/3dView/Data/BuildCableData";
import ModeChangeDialog from "bin/conditioning/modeChangeDialog";
import { cloneDeep } from "lodash";
import { useMobile } from "hooks";
interface Props {
bin: Bin
// cables?: GrainCable[]
devices: Device[]
// fans?: Controller[]
// heaters?: Controller[]
permissions: pond.Permission[]
componentDevices: Map<string, number>
componentMap: Map<string, Component>
binPrefs?: Map<string, pond.BinComponentPreferences>
updateBinCallback?: (bin: Bin) => void
}
export default function bin3dVisualizer(props: Props){
const {bin, permissions, devices, componentDevices, componentMap, binPrefs, updateBinCallback} = props
const theme = useTheme()
const [showHeatmap, setShowHeatmap] = useState(true)
// const [showHotspots, setShowHotspots] = useState(false)
// const [showFill, setShowFill] = useState(false)
const [showTemp, setShowTemp] = useState(true)
const [showMoisture, setShowMoisture] = useState(false)
const [showMoistureHeatmap, setShowMoistureHeatmap] = useState(false)
const [binDisplay, setBinDisplay] = useState<string>("temp")
const [binMode, setBinMode] = useState<pond.BinMode>(pond.BinMode.BIN_MODE_NONE)
const [openModeChange, setOpenModeChange] = useState(false)
const [selectedCable, setSelectedCable] = useState<CableData | undefined>(undefined)
const [selectedNode, setSelectedNode] = useState<NodeData | undefined>(undefined)
const [selectedCableDevice, setSelectedCableDevice] = useState(Device.create()) //this is the device that the cable that has the node that was clicked on belongs to
const [filteredComponents, setFilteredComponents] = useState<Component[]>([]) //components that are filtered to only include ones on the same device
const [openNodeControls, setOpenNodeControls] = useState(false)
const [devMap, setDevMap] = useState<Map<number, Device>>(new Map())
const [modeChangeInProgress, setModeChangeInProgress] = useState(false)
const [showLabels, setShowLabels] = useState(true)
const isMobile = useMobile()
useEffect(()=>{
let newMap: Map<number, Device> = new Map()
devices.forEach(d => {
newMap.set(d.id(), d)
})
setDevMap(newMap)
},[devices])
useEffect(()=>{
setBinMode(bin.settings.mode)
},[bin])
const updateBin = () => {
if(updateBinCallback){
let clone = cloneDeep(bin)
clone.settings.mode = binMode
updateBinCallback(clone)
}
};
const binModeControl = () => {
return (
<Box>
<Typography sx={{fontWeight: 650, fontSize: isMobile ? 13 : 17}}>{bin.name()}</Typography>
<FormControl fullWidth>
<Select
value={binMode}
disabled={modeChangeInProgress}
sx={{
borderRadius: 1,
bgcolor: '#151E27',
'& .MuiSelect-select': {
py: isMobile ? 0.5 : 1.5,
fontSize: 13
},
'& .MuiOutlinedInput-notchedOutline': { border: 'none' },
'&:hover .MuiOutlinedInput-notchedOutline': { border: 'none' },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { border: 'none' },
}}
onChange={event => {
setBinMode(event.target.value as pond.BinMode)
setOpenModeChange(true)
}}
>
<MenuItem value={pond.BinMode.BIN_MODE_NONE}>Select Mode..</MenuItem>
<MenuItem value={pond.BinMode.BIN_MODE_STORAGE}>Storage</MenuItem>
<MenuItem value={pond.BinMode.BIN_MODE_DRYING}>Drying</MenuItem>
<MenuItem value={pond.BinMode.BIN_MODE_HYDRATING}>Hydrating</MenuItem>
<MenuItem value={pond.BinMode.BIN_MODE_COOLDOWN}>Cooldown</MenuItem>
</Select>
</FormControl>
</Box>
)
}
const heatmapDisplay = () => {
return (
<Box>
<Typography noWrap sx={{fontWeight: 650, fontSize: isMobile ? 13 : 17}}>
Heatmap
</Typography>
<FormControl fullWidth>
<Select
value={binDisplay}
sx={{
borderRadius: 1,
bgcolor: '#151E27',
'& .MuiSelect-select': {
py: isMobile ? 0.5 : 1.5,
fontSize: 13
},
'& .MuiOutlinedInput-notchedOutline': { border: 'none' },
'&:hover .MuiOutlinedInput-notchedOutline': { border: 'none' },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { border: 'none' },
}}
onChange={event => {
let selection = event.target.value
setBinDisplay(selection)
if (selection === "temp") {
setShowHeatmap(true)
setShowTemp(true)
setShowMoisture(false)
setShowMoistureHeatmap(false)
}
if (selection === "moisture") {
setShowHeatmap(false)
setShowTemp(false)
setShowMoisture(true)
setShowMoistureHeatmap(true)
}
}}
>
<MenuItem value={"temp"}>Temperature</MenuItem>
<MenuItem value={"moisture"}>Moisture</MenuItem>
</Select>
</FormControl>
{/* Checkbox moved below dropdown — no longer affects top alignment */}
<FormControlLabel
control={
<Checkbox
size="small"
checked={showLabels}
onChange={(e, checked) => setShowLabels(checked)}
/>
}
label={<Typography noWrap sx={{fontSize: 11}}>Show Values</Typography>}
sx={{ mt: 0.5, ml: '-9px' }}
/>
</Box>
)
}
// const controllerState = (controller: Controller) => {
// let state = false
// controller.status.lastGoodMeasurement.forEach(um => {
// if(um.type === quack.MeasurementType.MEASUREMENT_TYPE_BOOLEAN){
// if(um.values.length > 0){
// let readings = um.values
// if(readings[readings.length-1].values.length > 0){
// let nodes = readings[readings.length-1].values
// if (nodes[nodes.length -1] === 1){
// state = true
// }
// }
// }
// }
// })
// return state
// }
const controllerDisplay = () => {
if ((bin.status.fans && bin.status.fans.length > 0) || (bin.status.heaters && bin.status.heaters.length > 0)){
return (
<Box width="100%">
<Typography sx={{fontWeight: 650, fontSize: isMobile ? 13 : 17}}>
Controllers
</Typography>
{bin.status.fans && bin.status.fans.map(fan => {
// let isOn = controllerState(fan)
return (
<Box
key={fan.key}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
// bgcolor: 'rgba(255,255,255,0.05)',
bgcolor: '#151E27',
borderRadius: 1,
px: 2,
py: 1,
}}
>
<Typography sx={{fontSize: 13}}>{fan.name}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{fontWeight: 650, fontSize: 13}}>
{fan.state ? "ON" : "OFF"}
</Typography>
<Box sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: fan.state ? 'success.main' : 'error.main'
}} />
</Box>
</Box>
)})}
{bin.status.heaters && bin.status.heaters.map(heater => {
// let isOn = controllerState(heater)
return (
<Box
key={heater.key}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
// bgcolor: 'rgba(255,255,255,0.05)',
bgcolor: '#151E27',
borderRadius: 1,
px: 2,
py: 1,
}}
>
<Typography sx={{fontSize: 13}}>{heater.name}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{fontWeight: 650, fontSize: 13}}>
{heater.state ? "ON" : "OFF"}
</Typography>
<Box sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: heater.state ? 'success.main' : 'error.main'
}} />
</Box>
</Box>
)})}
</Box>
// loop through controllers displaying each one
)
}else{
return
}
}
const desktopHUD = () => {
return (
<Box position={"absolute"} top={20} left={25} height={"75%"} width={"25%"}>
<Stack direction={"column"} justifyContent={"space-between"} sx={{height: "100%", width: "100%"}}>
{binModeControl()}
{heatmapDisplay()}
{controllerDisplay()}
</Stack>
</Box>
)
}
const mobileHUD = () => {
return (
<React.Fragment>
{/* Top-left: Bin Name / Mode */}
<Box
position="absolute"
top={12}
left={12}
width="40%"
sx={{ pointerEvents: 'none' }}
>
<Box sx={{ pointerEvents: 'auto' }}>
{binModeControl()}
</Box>
</Box>
{/* Top-right: Heatmap Display */}
<Box
position="absolute"
top={12}
right={12}
width="40%"
sx={{ pointerEvents: 'none' }}
>
<Box sx={{ pointerEvents: 'auto' }}>
{heatmapDisplay()}
</Box>
</Box>
{/* Bottom-left: Controller Status */}
<Box
position="absolute"
bottom={12}
left={12}
width="45%"
sx={{ pointerEvents: 'none' }}
>
<Box sx={{ pointerEvents: 'auto' }}>
{controllerDisplay()}
</Box>
</Box>
</React.Fragment>
)
}
return (
<React.Fragment>
{(selectedCable && selectedNode) &&
<NodeControls
bin={bin}
cable={selectedCable}
node={selectedNode}
open={openNodeControls}
permissions={permissions}
device={selectedCableDevice}
filteredComponents={filteredComponents}
componentMap={componentMap}
onClose={() => {
setOpenNodeControls(false)
}}
/>
}
<ModeChangeDialog
binKey={bin.key()}
binMode={binMode}
grain={bin.settings.inventory?.grainType}
devices={devices}
open={openModeChange}
compDevMap={componentDevices}
preferences={binPrefs}
onClose={(refresh) => {
setOpenModeChange(false)
if(refresh) {
updateBin()
}else{
setBinMode(bin.settings.mode)
};
}}
defaultTargetMoisture={bin.settings.inventory?.initialMoisture}
// defaultOutdoorTemp={outdoorTemp}
// defaultOutdoorHumidity={outdoorHumidity}
// changeTargetMoisture={newMoisture => {
// setTargetMoisture(newMoisture)
// }}
// changeOutdoorTemp={newTemp => {
// setOutdoorTemp(newTemp)
// }}
// changeOutdoorHumidity={newHumidity => {
// setOutdoorHumidity(newHumidity)
// }}
startChange={() => {
setModeChangeInProgress(true)
}}
changeComplete={() => {
setModeChangeInProgress(false)
}}
/>
<Box position={"relative"} height={600}>
<Bin3dView
height={isMobile ? 600 : undefined}
width={isMobile ? window.innerWidth : undefined}
bin={bin}
fillPercent={bin.fillPercent()/100}//expects a decimal between 0 and 1
nodeClick={(node, cable) => {
//this will be how to control the dialog to update the top nodes and node exclusion etc (make new component called NodeControls for this)
//use the cable data to get the device it belongs to
let d = devMap.get(cable.grainCable.device)
if(d !== undefined){
setSelectedNode(node)
setSelectedCable(cable)
setSelectedCableDevice(d)
//filter the controls so that only components on THIS device are options for new interactions, a side note of this using the components that are in the bins status
//is that it will indirectly also filter by bin as well so only components that are both on the bin and the same device as the cable will appear
let filtered: Component[] = []
if(bin.status.fans){
bin.status.fans.forEach((f) => {
if(componentDevices.get(f.key) === d.id()){
let comp = componentMap.get(f.key)
if(comp){
filtered.push(comp)
}
}
})
}
if(bin.status.heaters){
bin.status.heaters.forEach((h) => {
if(componentDevices.get(h.key) === d.id()){
let comp = componentMap.get(h.key)
if(comp){
filtered.push(comp)
}
}
})
}
//also make sure to push the cable into the list of filtered components for the source
let c = componentMap.get(cable.grainCable.key)
if(c !== undefined){
filtered.push(c)
}
setFilteredComponents(filtered)
setOpenNodeControls(true)
}
}}
scale={100}
showTempHeatmap={showHeatmap}
showMoistureHeatmap={showMoistureHeatmap}
showTemp={showTemp && showLabels}
showMoisture={showMoisture && showLabels}
xOffset={isMobile ? undefined : -120}
/>
{isMobile ? mobileHUD() : desktopHUD()}
</Box>
</React.Fragment>
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,415 @@
import { Box, Card, DialogContent, DialogTitle, Grid2, IconButton, LinearProgress, Skeleton, Slider, Switch, Tooltip, Typography } from "@mui/material";
import { useMobile } from "hooks";
import { Bin, Component, Device } from "models";
import Bin3dVisualizer from "../bin3dVisualizer";
// import GrassIcon from "@mui/icons-material/Grass"
import { grey, orange, red } from "@mui/material/colors";
import { AccessTime, Spa } from "@mui/icons-material";
import moment from "moment";
import { Controller } from "models/Controller";
import { GrainCable } from "models/GrainCable";
import { pond } from "protobuf-ts/pond";
import BinDetails from "./components/binDetails";
import { Plenum } from "models/Plenum";
import BinSensorsDisplay from "../binSensorsDisplay";
import ResponsiveDialog from "common/ResponsiveDialog";
import { useEffect, useRef, useState } from "react";
import SearchSelect from "common/SearchSelect";
import ChangeGrainDialog from "grain/ChangeGrainDialog";
import { useGlobalState } from "providers";
import GrainTransaction from "grain/GrainTransaction";
import { cloneDeep } from "lodash";
import BinPlayer from "bin/BinPlayer";
interface Props {
bin: Bin
loading?: boolean,
devices: Device[]
cables?: GrainCable[]
plenums?: Plenum[]
fans?: Controller[]
heaters?: Controller[]
permissions: pond.Permission[]
componentDevices: Map<string, number>
componentMap: Map<string, Component>
binPrefs?: Map<string, pond.BinComponentPreferences>
setPreferences: React.Dispatch<
React.SetStateAction<Map<string, pond.BinComponentPreferences> | undefined>
>;
updateBinCallback?: (bin: Bin) => void
setBin?: React.Dispatch<React.SetStateAction<Bin>>;
}
export default function BinSummary(props: Props){
const {bin, loading, devices, fans, heaters, permissions, componentDevices, componentMap, binPrefs, updateBinCallback, cables = [], plenums = [], setPreferences, setBin} = props
//const [currentDevice, setCurrentDevice] = useState<Device | undefined>(undefined)
const isMobile = useMobile()
const [openGrainDialog, setOpenGrainDialog] = useState(false)
const [manualFillPercent, setManualFillPercent] = useState(0)
const [manualBushels, setManualBushels] = useState(0)
const [{user}] = useGlobalState()
const fillCapRef = useRef<HTMLSpanElement>(null);
const [fillCapWidth, setFillCapWidth] = useState<number | undefined>(undefined);
const [openNewTransaction, setOpenNewTransaction] = useState(false)
useEffect(() => {
if (fillCapRef.current) {
// measure the max possible string width once
fillCapRef.current.textContent = `${bin.grainCapacity(user)}/${bin.grainCapacity(user)}`;
setFillCapWidth(fillCapRef.current.offsetWidth);
}
}, [bin, user]);
useEffect(()=>{
setManualFillPercent(bin.fillPercent())
setManualBushels(bin.bushels())
},[bin])
// useEffect(() => {
// if(devices.length > 0){
// setCurrentDevice(devices[0])
// }
// },[devices])
const inventoryControl = () => {
switch (bin.inventoryControl()) {
case pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL:
return "Manual"
case pond.BinInventoryControl.BIN_INVENTORY_CONTROL_AUTOMATIC:
return "Auto Cable"
case pond.BinInventoryControl.BIN_INVENTORY_CONTROL_AUTOMATIC_LIDAR:
return "Auto Lidar"
case pond.BinInventoryControl.BIN_INVENTORY_CONTROL_HYBRID_CABLE:
return "Hybrid Cable"
case pond.BinInventoryControl.BIN_INVENTORY_CONTROL_HYBRID_LIDAR:
return "Hybrid Lidar"
case pond.BinInventoryControl.BIN_INVENTORY_CONTROL_LIBRACART:
return "Libra Cart"
default:
return "Not Set"
}
}
const activity = (reading: string) => {
//if the device hasnt checked in in 6 hours = missing 12 hours = inactive within 6 hours = live
let status = "Live"
let colour = "#4caf50"
let diff = moment().diff(moment(reading), "hour")
if(diff > 12){
status = "Inactive"
colour = red[700]
}else if (diff > 6){
status = "Missing"
colour = orange[300]
}
return (
<Box display="flex" alignItems="center" gap={0.5}>
<Box
sx={{
width: 8,
height: 8,
borderRadius: "50%",
backgroundColor: colour,
}}
/>
<Typography variant="body2" sx={{ color: colour }}>
{status}
</Typography>
</Box>
)
}
const currentFill = () => {
if(bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL){
if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && bin.bushelsPerTonne() > 1){
return Math.round((manualBushels / bin.bushelsPerTonne())*10)/10
}else if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && bin.bushelsPerTonne() > 1){
return Math.round((manualBushels / (bin.bushelsPerTonne() * 0.907))*10)/10
}
return Math.round(manualBushels)
}
return bin.grainInventory(user)
}
const inventorySummaryMobile = () => (
<Card raised sx={{ mb: 2, overflow: "hidden", borderRadius: 4 }}>
{/* Header row */}
<Box display="flex" alignItems="center" justifyContent="space-between" sx={{ px: 2, pt: 1.5, pb: 1.25 }}>
<Box display="flex" alignItems="center" gap={1}>
<Box sx={{ width: 30, height: 30, borderRadius: 2, backgroundColor: "rgba(76,175,80,0.15)", display: "flex", alignItems: "center", justifyContent: "center" }}
onClick={() => setOpenGrainDialog(true)}>
<Spa sx={{ color: "#4caf50", fontSize: 18 }} />
</Box>
<Box>
<Typography variant="caption" sx={{ color: grey[500], display: "block" }}>Grain type</Typography>
<Typography variant="body2" sx={{ fontWeight: 500, fontSize: 14 }}>{bin.grainName()}</Typography>
</Box>
</Box>
<Box display="flex" alignItems="center" gap={0.75} sx={{ textAlign: "right" }}>
<Box sx={{ width: 7, height: 7, borderRadius: "50%", backgroundColor: "#4caf50", flexShrink: 0 }} />
<Box>
<Typography variant="caption" sx={{ color: grey[500], display: "block" }}>Last updated</Typography>
<Typography variant="body2" sx={{ fontSize: 13 }}>{moment(bin.status.timestamp).fromNow()}</Typography>
</Box>
</Box>
</Box>
{/* Fill row */}
<Box sx={{ borderTop: `1px solid ${grey[800]}`, px: 2, pt: 1.5, pb: 1.75 }}>
<Box display="flex" alignItems="baseline" justifyContent="space-between" sx={{ mb: 1 }}>
<Typography variant="caption" sx={{ color: grey[500] }}>Bin fill ({inventoryControl()})</Typography>
<Box display="flex" alignItems="baseline" gap={0.75}>
<Typography sx={{ fontSize: 20, fontWeight: 500 }}>
{bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL
? manualFillPercent : bin.fillPercent()}%
</Typography>
<Typography variant="caption" sx={{ color: grey[500] }}>
{currentFill().toLocaleString()} / {bin.grainCapacity(user).toLocaleString()}
</Typography>
</Box>
</Box>
{bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL ? (
<Box sx={{ display: "flex", alignItems: "center", marginY: -2.5 }}>
<Slider value={manualFillPercent} onChangeCommitted={() => setOpenNewTransaction(true)}
onChange={(_, val) => { setManualFillPercent(val as number); setManualBushels((val as number / 100) * bin.bushelCapacity()); }}
min={0} max={100} sx={{
color: "#4caf50",
py: 0,
mt: 0,
mb: 0,
height: 4,
"& .MuiSlider-root": { py: 0 },
"& .MuiSlider-thumb": { width: 16, height: 16 },
"& .MuiSlider-rail": { backgroundColor: "rgba(255,255,255,0.1)" },
}} />
</Box>
) : (
<LinearProgress variant="determinate" value={bin.fillPercent()}
sx={{ height: 8, borderRadius: 4, backgroundColor: "rgba(255,255,255,0.1)", "& .MuiLinearProgress-bar": { backgroundColor: "#4caf50", borderRadius: 4 } }} />
)}
</Box>
</Card>
);
const inventorySummaryDesktop = () => {
return (
<Card raised sx={{ marginBottom: 2, borderRadius: 4 }}>
<Box display="flex" justifyContent="space-between" alignItems="flex-start" sx={{ padding: 2 }}>
{/* Grain Type */}
<Box>
<Typography variant="caption" sx={{ color: grey[500] }}>
Grain Type
</Typography>
<Box
display="flex"
alignItems="center"
gap={0.5}
onClick={() => setOpenGrainDialog(true)}
sx={{
cursor: "pointer",
mt: 0.5,
px: 1,
py: 0.5,
borderRadius: 1,
border: "1px solid",
borderColor: grey[700],
"&:hover": {
borderColor: grey[500],
backgroundColor: "rgba(255,255,255,0.05)",
},
}}
>
<Spa sx={{ color: "#4caf50", fontSize: 18 }} />
<Typography variant="body1">{bin.grainName()}</Typography>
</Box>
</Box>
{/* Bin Fill */}
<Box sx={{ flexGrow: 1, maxWidth: 450, mx: 4 }}>
<Typography variant="caption" sx={{ color: grey[500] }}>
Bin Fill ({inventoryControl()})
</Typography>
<Box display="flex" alignItems="center" gap={1} sx={{ mt: 0.5 }}>
{/* hidden measuring span, renders off-screen */}
<span
ref={fillCapRef}
style={{
position: "absolute",
visibility: "hidden",
whiteSpace: "nowrap",
fontSize: "0.875rem", // match body2
}}
/>
<Typography
variant="body2"
noWrap
sx={{
width: (bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL || bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_UNKNOWN) ? fillCapWidth : undefined, flexShrink: 0
}}>
{currentFill()}/{bin.grainCapacity(user)}
</Typography>
{bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL ?
<Slider
value={manualFillPercent}
onChangeCommitted={() => {
setOpenNewTransaction(true)
}}
onChange={(_, val) => {
let fillPercent = val as number
setManualFillPercent(fillPercent)
let newBushels = (fillPercent/100) * bin.bushelCapacity()
setManualBushels(newBushels)
//this does casue the 3d bin to fill as you drag it but it throws off the transaction and the reset if you cancel
// let clone = cloneDeep(bin)
// let inventory = clone.settings.inventory ?? pond.BinInventory.create()
// inventory.grainBushels = newBushels
// if(setBin){
// setBin(clone)
// }
}}
min={0}
max={100}
sx={{
flexGrow: 1,
color: "#4caf50",
"& .MuiSlider-thumb": {
width: 16,
height: 16,
},
"& .MuiSlider-rail": {
backgroundColor: "rgba(255,255,255,0.1)",
},
}}
/>
:
<LinearProgress
variant="determinate"
value={bin.fillPercent()}
sx={{
flexGrow: 1,
height: 8,
borderRadius: 4,
backgroundColor: "rgba(255,255,255,0.1)",
"& .MuiLinearProgress-bar": {
backgroundColor: "#4caf50",
borderRadius: 4,
},
}}
/>
}
<Typography variant="body2" noWrap sx={{ width: 100 }}>
{bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL ? manualFillPercent : bin.fillPercent()}%
</Typography>
</Box>
</Box>
{/* Last Updated */}
<Box>
<Typography variant="caption" sx={{ color: grey[500] }}>
Last Updated
</Typography>
<Tooltip title="Bin Status will update whenever its settings change or a device with a connected sensor checks in">
<Box display="flex" alignItems="center" gap={2} sx={{ mt: 0.5 }}>
<Box display="flex" alignItems="center" gap={1}>
<AccessTime sx={{ fontSize: 16, color: grey[400] }} />
<Typography variant="body2" sx={{ color: grey[300] }}>
{moment(bin.status.timestamp).fromNow()}
</Typography>
</Box>
{activity(bin.status.timestamp)}
</Box>
</Tooltip>
</Box>
</Box>
</Card>
);
};
return (
<Box padding={2}>
<GrainTransaction
open={openNewTransaction}
mainObject={bin}
grainAdjustment={manualBushels - bin.bushels()}
close={(confirmed) => {
//this resets all of the state variables used
setOpenNewTransaction(false);
if(!confirmed){
setManualBushels(bin.bushels())
setManualFillPercent(bin.fillPercent())
}else{
let clone = cloneDeep(bin)
let inventory = clone.settings.inventory ?? pond.BinInventory.create()
inventory.grainBushels = manualBushels
if(setBin){
setBin(clone)
}
}
}}
/>
<ChangeGrainDialog bin={bin} open={openGrainDialog} closeDialog={()=>setOpenGrainDialog(false)} permissions={permissions} updateBin={updateBinCallback}/>
{loading ? <Skeleton variant="rectangular" height={100} sx={{marginBottom: 2}} /> : isMobile ? inventorySummaryMobile() : inventorySummaryDesktop()}
<Grid2 container spacing={2}>
{!isMobile &&
<Grid2 size={isMobile ? 12 : 7}>
{loading ?
<Skeleton variant="rectangular" height={600}/>
:
<Card raised sx={{height: 600, position: "relative", borderRadius: 4}}>
<Bin3dVisualizer
bin={bin}
permissions={permissions}
devices={devices}
componentDevices={componentDevices}
componentMap={componentMap}
binPrefs={binPrefs}
updateBinCallback={updateBinCallback}
/>
{setBin &&
<Box padding={3} position="absolute" bottom={3} width="100%">
<BinPlayer bin={bin} componentDevices={componentDevices} setBin={setBin}/>
</Box>
}
</Card>
}
</Grid2>
}
<Grid2 size={isMobile ? 12 : 5}>
{loading ?
<Skeleton variant="rectangular" height={600}/>
:
<Card raised sx={{ height: 600, display: "flex", flexDirection: "column", overflow: "hidden", borderRadius: 4 }}>
<BinDetails
bin={bin}
updateBinCallback={updateBinCallback}
componentDevices={componentDevices}
devices={devices}
linkedComponents={componentMap}
permissions={permissions}
cables={cables}
plenums={plenums}
fans={fans}
heaters={heaters}
binPrefs={binPrefs}
componentMap={componentMap}
setBin={setBin}/>
</Card>
}
</Grid2>
<Grid2 size={12}>
{loading ?
<Skeleton variant="rectangular" height={250}/>
:
<Card sx={{borderRadius: 4}}>
<BinSensorsDisplay components={componentMap} bin={bin} preferences={binPrefs} setPreferences={setPreferences} componentDevices={componentDevices}/>
</Card>
}
</Grid2>
</Grid2>
</Box>
)
}

View file

@ -0,0 +1,137 @@
import { LinearProgress } from "@mui/material";
import { Bin, Component, Device, Interaction } from "models";
import Alerts, { Alert } from "objects/objectInteractions/Alerts";
import NewObjectInteraction from "objects/objectInteractions/NewObjectInteraction";
import { extension } from "pbHelpers/ComponentType";
import { pond } from "protobuf-ts/pond";
import { useGlobalState, useInteractionsAPI } from "providers";
import React, { useCallback, useEffect, useState } from "react";
interface Props {
linkedComponents: Map<string, Component>; //the component key to the component object
componentDevices: Map<string, number>;
permissions: pond.Permission[]
}
export default function BinAlerts(props: Props){
const {linkedComponents, componentDevices, permissions} = props
const [{as}] = useGlobalState();
const interactionsAPI = useInteractionsAPI();
//list of alerts, each alert contains a list of interactions with matching conditions
const [alerts, setAlerts] = useState<Alert[]>([]);
const [loading, setLoading] = useState(false)
const [newAlert, setNewAlert] = useState(false)
const matchConditions = (interaction: Interaction, set: Alert) => {
//if the number of conditions are not the same they are different
if (interaction.settings.conditions.length !== set.conditions.length) {
return false;
}
//continue with the comparison
let matching = true;
interaction.settings.conditions.forEach((condition, i) => {
if (
condition.comparison !== set.conditions[i].comparison ||
condition.measurementType !== set.conditions[i].measurementType ||
condition.value !== set.conditions[i].value
) {
matching = false;
}
});
return matching;
};
const load = useCallback(()=>{
if(!loading){
//load the interactions for the linked components
setLoading(true)
const newInteractions: Interaction[] = [];
const interactionSourceMap = new Map<string, string>();
const sinkOptions: Component[] = [];
const run = async () => {
// Run all component API fetches in parallel
await Promise.all(
[...linkedComponents].map(async ([key, comp]) => {
const device = componentDevices.get(key);
if (device) {
const resp = await interactionsAPI.listInteractionsByComponent(
device,
comp.location(),
undefined,
as
);
resp.forEach(interaction => interactionSourceMap.set(interaction.key(), key));
newInteractions.push(...resp);
}
// Collect controller components
if (extension(comp.type()).isController) {
sinkOptions.push(comp);
}
})
);
};
run().then(() => {
newInteractions.forEach(interaction => {
//alert and control are not mutally exclusive, it is possible to be both if the control is sending notifications
if (interaction.isAlert()) {
// Find matching alert
let similarAlertIndex = alerts.findIndex(alert =>
matchConditions(interaction, alert)
);
const compKey = interactionSourceMap.get(interaction.key());
const component = compKey ? linkedComponents.get(compKey) : undefined;
if (component) {
if (similarAlertIndex === -1) {
alerts.push({
conditions: interaction.settings.conditions,
sourceComponents: [component],
});
} else {
alerts[similarAlertIndex].sourceComponents.push(component);
}
}
}
});
setAlerts(alerts);
}).catch(err => {
console.error("Interaction fetch error:", err)
}).finally(() => {
setLoading(false)
})
}
},[linkedComponents, interactionsAPI, componentDevices, as])
useEffect(()=>{
load()
},[load])
return (
<React.Fragment>
<NewObjectInteraction
open={newAlert}
onClose={(refresh) => {
setNewAlert(false)
if(refresh){
load()
}
}}
linkedComponents={linkedComponents}
componentDevices={componentDevices}/>
{loading ?
<LinearProgress />
:
<Alerts alerts={alerts} permissions={permissions} addNew={() => {setNewAlert(true)}}/>
}
</React.Fragment>
)
}

View file

@ -0,0 +1,410 @@
import { Avatar, Box, Button, CardHeader, Checkbox, FormControlLabel, Grid2, Theme, Tooltip, Typography } from "@mui/material";
import { useMobile, useThemeType } from "hooks";
import VPDDark from "assets/products/Ag/dryingDark.png";
import VPDLight from "assets/products/Ag/dryingLight.png";
import TrendLight from "assets/products/Ag/trendingLight.png";
import TrendDark from "assets/products/Ag/trendingDark.png";
import { useEffect, useState } from "react";
import { makeStyles } from "@mui/styles";
import moment, { Moment } from "moment";
import { blue, orange, teal } from "@mui/material/colors";
import { GrainDryingPoint } from "charts/GrainDryingChart";
import { GrainCable } from "models/GrainCable";
import { Plenum } from "models/Plenum";
import BinGraphsVPD from "bin/graphs/BinGraphsVPD";
import { UnitMeasurement } from "models/UnitMeasurement";
import { Bin } from "models";
import { useBinAPI, useGlobalState } from "providers";
import TimeBar from "common/time/TimeBar";
import { GetDefaultDateRange } from "common/time/DateRange";
import { ZoomOut } from "@mui/icons-material";
import BinGraphsTrending from "bin/graphs/BinGraphsTrending";
import { DataPoint, TrendPoint } from "charts/TrendingChart";
import WaterLight from "assets/products/Ag/waterContentLight.png";
import WaterDark from "assets/products/Ag/waterContentDark.png";
import { Pressure } from "models/Pressure";
import { DataPoint as WaterPoint } from "charts/WaterLevelChart";
import BinWaterLevel from "bin/graphs/BinWaterLevel";
import { Controller } from "models/Controller";
import { pond } from "protobuf-ts/pond";
interface Props {
cables: GrainCable[]
plenums: Plenum[]
componentDevices: Map<string, number>
bin: Bin
}
const useStyles = makeStyles((theme: Theme) =>{
return ({
root: {
display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
},
toolbar: {
flexShrink: 0,
},
scrollContent: {
flex: 1,
minHeight: 0,
overflowY: "auto",
},
card: {
position: "relative",
display: "flex",
height: "100%",
flexDirection: "column",
overflow: "visible"
},
cardHeader: {
padding: theme.spacing(1),
paddingLeft: theme.spacing(2),
marginRight: 10
},
avatarIcon: {
width: 33,
height: 33
}
})
});
export default function BinAnalysisGraphs(props: Props){
const {cables, plenums, componentDevices, bin} = props
const themeType = useThemeType()
const classes = useStyles()
const [{user, as, showErrors}, dispatch] = useGlobalState()
const isMobile = useMobile()
const [xDomain, setXDomain] = useState<string[] | number[]>(["dataMin", "dataMax"]);
const [zoomed, setZoomed] = useState(false);
const [recentVPD, setRecentVPD] = useState<GrainDryingPoint | undefined>();
const [loading, setLoading] = useState(false)
// map using the component key and the unitmeasurements for that component
const [compMeasurements, setCompMeasurements] = useState<Map<string, UnitMeasurement[]>>(
new Map<string, UnitMeasurement[]>()
);
const binAPI = useBinAPI()
const defaultDateRange = GetDefaultDateRange()
const [startDate, setStartDate] = useState<Moment>(defaultDateRange.start);
const [endDate, setEndDate] = useState<Moment>(defaultDateRange.end);
const [recentPlenumTrend, setRecentPlenumTrend] = useState<TrendPoint | undefined>();
const [recentCableTrend, setRecentCableTrend] = useState<DataPoint | undefined>();
const [recentWaterContent, setRecentWaterContent] = useState<WaterPoint | undefined>();
useEffect(() => {
let measurementMap: Map<string, UnitMeasurement[]> = new Map<string, UnitMeasurement[]>();
if (!loading) {
setLoading(true);
//check if the bin has a fan to use the cfm in the drying graph
// if (bin.settings.fan?.type !== pond.FanType.FAN_TYPE_UNKNOWN) {
// setIncludeCFM(true);
// }
binAPI
.listBinComponentsMeasurements(
bin.key(),
startDate.toISOString(),
endDate.toISOString(),
showErrors,
showErrors,
as
)
.then(resp => {
resp.data.measurements.forEach((um: any) => {
let unitMeasurement = UnitMeasurement.any(um, user);
let entry = measurementMap.get(unitMeasurement.componentId);
if (entry) {
entry.push(unitMeasurement);
} else {
measurementMap.set(unitMeasurement.componentId, [unitMeasurement]);
}
});
setCompMeasurements(measurementMap);
setLoading(false);
});
}
}, [bin, binAPI, startDate, endDate, user, as]); // eslint-disable-line react-hooks/exhaustive-deps
const zoomOut = () => {
setXDomain(["dataMin", "dataMax"]);
setZoomed(false);
};
const zoomIn = (domain: string[] | number[]) => {
if (!isMobile) {
setXDomain(domain);
setZoomed(true);
}
};
const updateDateRange = (newStartDate: any, newEndDate: any) => {
let range = GetDefaultDateRange();
range.start = newStartDate;
range.end = newEndDate;
setStartDate(newStartDate);
setEndDate(newEndDate);
};
//this uses the components found in the bins status
const waterGraph = () => {
let moistureCables: pond.GrainCable[] = [];
bin.status.grainCables.forEach(cable => {
if (cable.relativeHumidity.length > 0) {
moistureCables.push(cable);
}
});
if (bin.status.plenums[0] && moistureCables[0] && bin.status.pressures[0] && bin.supportedGrain()) {
return (
<Box>
<BinWaterLevel
bin={bin}
// range={{ start: startDate, end: endDate }}
start={startDate.toISOString()}
end={endDate.toISOString()}
plenumKey={componentDevices.get(bin.status.plenums[0].key) + ":" + bin.status.plenums[0].key}
cableKey={componentDevices.get(moistureCables[0].key) + ":" + moistureCables[0].key}
pressureKey={componentDevices.get(bin.status.pressures[0].key) + ":" + bin.status.pressures[0].key}
fanKey={
bin.status.fans.length > 0
? componentDevices.get(bin.status.fans[0].key) + ":" + bin.status.fans[0].key
: undefined
}
newXDomain={xDomain}
multiGraphZoom={zoomIn}
multiGraphZoomOut
returnLast={lastWater => {
setRecentWaterContent(lastWater);
}}
/>
</Box>
);
} else {
return (
<Box minHeight={100} display="flex" justifyContent="center" alignItems="center">
<Typography style={{ fontWeight: 650 }}>
Plenum with pressure and moisture cable as well as an initial moisture and supported
grain type set in the bin settings required to calculate Water Content
</Typography>
</Box>
);
}
};
return (
<Box className={classes.root}>
<Box className={classes.toolbar} display="flex" justifyContent="space-between">
<Box>
<TimeBar startDate={startDate} endDate={endDate} updateDateRange={updateDateRange}/>
</Box>
<Box>
{zoomed && (
<Tooltip title="Zoom Out Graphs">
<Button variant="outlined" onClick={zoomOut}>
<ZoomOut />
</Button>
</Tooltip>
)}
<FormControlLabel
label="Show Errors"
control={
<Checkbox
checked={showErrors}
onChange={() => {
//setShowErrors(!showErrors);
dispatch({ key: "showErrors", value: !showErrors });
}}
/>
}
/>
</Box>
</Box>
<Box className={classes.scrollContent}>
<CardHeader
avatar={
<Avatar
variant="square"
src={themeType === "light" ? VPDDark : VPDLight}
className={classes.avatarIcon}
alt={"Drying Score"}
/>
}
title={
<Grid2 container direction="row" justifyContent="space-between">
<Grid2 >
<Typography style={{ fontSize: 25, fontWeight: 650 }}>
Drying vs. Hydrating
</Typography>
</Grid2>
</Grid2>
}
subheader={
recentVPD ? (
<Grid2 container>
<Grid2 size={{ xs: 12 }}>
<span>
<span style={{ color: recentVPD.dryScore > 0 ? orange[500] : blue[500] }}>
{recentVPD.dryScore > 0 ? "drying" : "hydrating"}
</span>
<span>{" : "}</span>
<span
style={{
color: recentVPD.dryScore > 0 ? orange[500] : blue[500],
fontWeight: 500
}}>
{recentVPD.dryScore.toFixed(2)}
</span>
<br />
</span>
</Grid2>
<Grid2 size={{ xs: 12 }}>
<Typography color="textSecondary" variant={"caption"}>
{moment(recentVPD.timestamp).fromNow()}
</Typography>
</Grid2>
</Grid2>
) : (
<Typography variant="body1" color="textPrimary">
No Data
</Typography>
)
}
/>
<Box padding={1}>
{cables.length > 0 && plenums.length > 0 ? (
<BinGraphsVPD
cables={cables}
plenums={plenums}
measurementMap={compMeasurements}
newXDomain={xDomain}
multiGraphZoom={zoomIn}
multiGraphZoomOut
//perBushelCFM={includeCFM ? bin.status.cfmPerBushel : undefined}
returnLastVPD={lastVPD => {
setRecentVPD(lastVPD);
}}
/>
) : (
<Box minHeight={100} display="flex" justifyContent="center" alignItems="center">
<Typography style={{ fontWeight: 650 }}>
Plenum and moisture cable required to calculate VPD
</Typography>
</Box>
)}
</Box>
<CardHeader
avatar={
<Avatar
variant="square"
src={themeType === "light" ? TrendDark : TrendLight}
className={classes.avatarIcon}
alt={"VPD"}
/>
}
title={
<Typography style={{ fontSize: 25, fontWeight: 650 }}>Moisture Trending</Typography>
}
subheader={
recentPlenumTrend && recentCableTrend ? (
<Grid2 container>
<Grid2 size={{ xs: 12 }}>
<span>
{"Plenum Moisture: "}
<span style={{ color: orange[500], fontWeight: 500 }}>
{recentPlenumTrend.trend.toFixed(2)}
</span>
<br />
</span>
<span>
{"Grain Moisture: "}
<span style={{ color: teal[500], fontWeight: 500 }}>
{recentCableTrend.moisture.toString()}
</span>
<br />
</span>
</Grid2>
<Grid2 size={{ xs: 12 }}>
<Typography color="textSecondary" variant={"caption"}>
{recentPlenumTrend.timestamp > recentCableTrend.timestamp
? moment(recentPlenumTrend.timestamp).fromNow()
: moment(recentCableTrend.timestamp).fromNow()}
</Typography>
</Grid2>
</Grid2>
) : (
<Typography variant="body1" color="textPrimary">
No Data
</Typography>
)
}
/>
<Box padding={1}>
{cables.length > 0 && plenums.length > 0 ? (
<BinGraphsTrending
cables={cables}
plenums={plenums}
measurementMap={compMeasurements}
bin={bin}
newXDomain={xDomain}
multiGraphZoom={zoomIn}
multiGraphZoomOut
returnLastMeasurements={(lastData, lastTrend) => {
setRecentCableTrend(lastData);
setRecentPlenumTrend(lastTrend);
}}
/>
) : (
<Box minHeight={100} display="flex" justifyContent="center" alignItems="center">
<Typography style={{ fontWeight: 650 }}>
Plenum and moisture cable required to calculate trending data
</Typography>
</Box>
)}
</Box>
<CardHeader
avatar={
<Avatar
variant="square"
src={themeType === "light" ? WaterDark : WaterLight}
className={classes.avatarIcon}
alt={"VPD"}
/>
}
title={
<Typography style={{ fontSize: 25, fontWeight: 650 }}>Grain Water Content</Typography>
}
subheader={
recentWaterContent &&
bin.settings.inventory &&
bin.settings.inventory.initialMoisture > 0 ? (
<Grid2 container>
<Grid2 size={{ xs: 12 }}>
<span>
{"Water Content: "}
<span style={{ color: blue[500], fontWeight: 500 }}>
{recentWaterContent.liters && !isNaN(recentWaterContent.liters)
? recentWaterContent.liters.toFixed(2)
: ""}
</span>
<br />
</span>
</Grid2>
<Grid2 size={{ xs: 12 }}>
<Typography color="textSecondary" variant={"caption"}>
{moment(recentWaterContent.timestamp).fromNow()}
</Typography>
</Grid2>
</Grid2>
) : (
<Typography variant="body1" color="textPrimary">
No Data
</Typography>
)
}
/>
<Box padding={1}>
{waterGraph()}
</Box>
</Box>
</Box>
)
}

View file

@ -0,0 +1,188 @@
import { LinearProgress } from "@mui/material";
import { Bin, Component, Device, Interaction } from "models";
import Controls, { Control } from "objects/objectInteractions/Controls";
import NewObjectInteraction from "objects/objectInteractions/NewObjectInteraction";
import { sameComponentID } from "pbHelpers/Component";
import { extension } from "pbHelpers/ComponentType";
import { pond } from "protobuf-ts/pond";
import { quack } from "protobuf-ts/quack";
import { useGlobalState } from "providers";
import interactionsAPI, { useInteractionsAPI } from "providers/pond/interactionsAPI";
import React, { useCallback, useEffect, useState } from "react";
interface Props {
linkedComponents: Map<string, Component>; //the component key to the component object
componentDevices: Map<string, number>;
devices: Device[];
permissions: pond.Permission[]
}
export default function BinControls(props: Props) {
const {linkedComponents, componentDevices, devices, permissions} = props
const [{as}] = useGlobalState()
const interactionsAPI = useInteractionsAPI()
const [controls, setControls] = useState<Control[]>([]);
const [selectedDevice, setSelectedDevice] = useState<Device | undefined>(undefined)
const [openNewInteraction, setOpenNewInteraction] = useState(false)
const [loading, setLoading] = useState(false)
const findDevice = (deviceID: number) => {
for (let device of devices) {
if (device.id() === deviceID) return device
}
}
const findSinkComponent = (sinks: Component[], interactionSink: quack.ComponentID, sourceDevice: number) => {
for (let component of sinks) {
if (sameComponentID(component.location(), interactionSink) && sourceDevice === componentDevices.get(component.key())){
return component
}
}
}
const matchConditions = (interaction: Interaction, set: Control) => {
//if the number of conditions are not the same they are different
if (interaction.settings.conditions.length !== set.conditions.length) {
return false;
}
//continue with the comparison
let matching = true;
interaction.settings.conditions.forEach((condition, i) => {
if (
condition.comparison !== set.conditions[i].comparison ||
condition.measurementType !== set.conditions[i].measurementType ||
condition.value !== set.conditions[i].value
) {
matching = false;
}
});
return matching;
};
const matchResult = (interaction: Interaction, set: Control) => {
let interactionResult = interaction.settings.result
let setResult = set.result
let matching = true
// if anything in the result for the interaction is different from the set make matching false
if(interactionResult?.type !== setResult?.type ||
interactionResult?.mode !== setResult?.mode ||
interactionResult?.value !== setResult?.value ||
interactionResult?.dutyCycle !== setResult?.dutyCycle
){
matching = false
}
return matching
}
const load = useCallback(()=>{
if(!loading){
setLoading(true)
const newInteractions: Interaction[] = [];
const interactionSourceMap = new Map<string, string>();
const sinkOptions: Component[] = [];
const controls: Control[] = [];
const run = async () => {
// Run all component API fetches in parallel
await Promise.all(
[...linkedComponents].map(async ([key, comp]) => {
const device = componentDevices.get(key);
if (device) {
const resp = await interactionsAPI.listInteractionsByComponent(
device,
comp.location(),
undefined,
as
);
resp.forEach(interaction => interactionSourceMap.set(interaction.key(), key));
newInteractions.push(...resp);
}
// Collect controller components
if (extension(comp.type()).isController) {
sinkOptions.push(comp);
}
})
);
};
run().then(() => {
newInteractions.forEach(interaction => {
if (interaction.isControl()) {
if (!interaction.settings.sink) return //if there is no sink it cant be a control so move on to the next
const compKey = interactionSourceMap.get(interaction.key());
if (!compKey) return //we have to have a valid component key
const component = linkedComponents.get(compKey);
const deviceID = componentDevices.get(compKey);
if (!deviceID) return //we have to know which device it is for
const controlDevice = findDevice(deviceID)
if (!controlDevice) return //failed to get the device from the array
const sinkComponent = findSinkComponent(sinkOptions, interaction.settings.sink, deviceID)
if (!sinkComponent) return //failed to get the correct sink component
let similarControlIndex = controls.findIndex(control =>
matchConditions(interaction, control) &&
matchResult(interaction, control) &&
sameComponentID(interaction.settings.sink, control.sinkComponent.location()) && //if the sink for the interaction is the same address as the existing control
deviceID === control.device.id() //if the device id for source component is the same as the device id in the control
);
if (component && interaction.settings.result) {
if (similarControlIndex === -1) {
controls.push({
conditions: interaction.settings.conditions,
result: interaction.settings.result,
sourceComponents: [component],
sinkComponent: sinkComponent,
device: controlDevice
});
} else {
controls[similarControlIndex].sourceComponents.push(component);
}
}
}
});
setControls(controls);
}).catch(err => {
console.error("Interaction fetch error:", err)
}).finally(() => {
setLoading(false)
})
}
},[linkedComponents, interactionsAPI, componentDevices, as])
useEffect(() => {
load()
}, [load]);
return (
<React.Fragment>
<NewObjectInteraction
open={openNewInteraction}
onClose={(refresh) => {
setOpenNewInteraction(false)
if(refresh){
load()
}
}}
linkedComponents={linkedComponents}
componentDevices={componentDevices}
device={selectedDevice}/>
{loading ?
<LinearProgress />
:
<Controls
controls={controls}
devices={devices}
permissions={permissions}
addNew={(device) => {
console.log("new control")
setSelectedDevice(device)
setOpenNewInteraction(true)
}}
/>
}
</React.Fragment>
)
}

View file

@ -0,0 +1,130 @@
import { Box, Tab, Tabs } from "@mui/material";
import React from "react";
import { useState } from "react";
import BinTableView from "../../binTableView";
import { Bin, Component, Device } from "models";
import Alerts from "objects/objectInteractions/Alerts";
import BinAlerts from "./binAlerts";
import { pond } from "protobuf-ts/pond";
import BinControls from "./binControls";
import BinAnalysisGraphs from "./binAnalysisGraphs";
import { GrainCable } from "models/GrainCable";
import { Plenum } from "models/Plenum";
import { Controller } from "models/Controller";
import { useMobile } from "hooks";
import Bin3dVisualizer from "bin/bin3dVisualizer";
import BinPlayer from "bin/BinPlayer";
interface Props {
bin: Bin
updateBinCallback?: (bin: Bin) => void
linkedComponents: Map<string, Component>; //the component key to the component object
componentDevices: Map<string, number>;
devices: Device[];
permissions: pond.Permission[]
cables: GrainCable[]
plenums: Plenum[]
showBinTab?: boolean
fans?: Controller[]
heaters?: Controller[]
componentMap: Map<string, Component>
binPrefs?: Map<string, pond.BinComponentPreferences>
setBin?: React.Dispatch<React.SetStateAction<Bin>>;
}
interface TabPanelProps {
children?: React.ReactNode;
index: any;
value: any;
}
function TabPanelMine(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<Box
role="tabpanel"
hidden={value !== index}
aria-labelledby={`simple-tab-${index}`}
sx={{
display: value === index ? "flex" : "none",
flexDirection: "column",
flex: 1,
minHeight: 0,
overflow: "hidden",
}}
{...other}>
{value === index && children}
</Box>
);
}
export default function BinDetails (props: Props) {
const {bin, updateBinCallback, linkedComponents, componentDevices, devices, permissions, cables, plenums, showBinTab, fans, heaters, componentMap, binPrefs, setBin} = props
const isMobile = useMobile()
const [currentTab, setCurrentTab] = useState((isMobile || showBinTab) ? "bin" : "table")
//this is where we will have the tabs and tab panels for the table view, alerts, controls, and analysis
return (
<Box sx={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden" }}>
<Tabs
value={currentTab}
onChange={(_, value) => setCurrentTab(value)}
indicatorColor="primary"
textColor="primary"
variant={isMobile ? "scrollable" : "fullWidth"}
scrollButtons="auto"
allowScrollButtonsMobile
sx={{
flexShrink: 0,
'& .MuiTabs-scroller': {
overflowX: 'auto !important',
}
}}>
{isMobile && <Tab label={"Bin"} value={"bin"} />}
<Tab label={"Table"} value={"table"} />
<Tab label={"Alerts"} value={"alerts"} />
<Tab label={"Controls"} value={"controls"} />
<Tab label={"Analysis"} value={"analysis"} />
</Tabs>
<Box sx={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0, overflow: "hidden" }}>
{(isMobile || showBinTab) &&
<TabPanelMine value={currentTab} index={"bin"}>
<Bin3dVisualizer
bin={bin}
permissions={permissions}
devices={devices}
componentDevices={componentDevices}
componentMap={componentMap}
binPrefs={binPrefs}
updateBinCallback={updateBinCallback}
/>
{setBin &&
<Box padding={1}>
<BinPlayer bin={bin} componentDevices={componentDevices} setBin={setBin}/>
</Box>
}
</TabPanelMine>
}
<TabPanelMine value={currentTab} index={"table"}>
<BinTableView bin={bin} updateBinCallback={updateBinCallback}/>
{setBin && isMobile &&
<Box padding={1}>
<BinPlayer bin={bin} componentDevices={componentDevices} setBin={setBin}/>
</Box>
}
</TabPanelMine>
<TabPanelMine value={currentTab} index={"alerts"}>
<BinAlerts componentDevices={componentDevices} linkedComponents={linkedComponents} permissions={permissions}/>
</TabPanelMine>
<TabPanelMine value={currentTab} index={"controls"}>
<BinControls componentDevices={componentDevices} devices={devices} linkedComponents={linkedComponents} permissions={permissions}/>
</TabPanelMine>
<TabPanelMine value={currentTab} index={"analysis"}>
<BinAnalysisGraphs bin={bin} cables={cables} plenums={plenums} componentDevices={componentDevices}/>
</TabPanelMine>
</Box>
</Box>
)
}

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

466
src/bin/binTableView.tsx Normal file
View file

@ -0,0 +1,466 @@
import { Edit, GppGood, Info, Save } from "@mui/icons-material";
import { Box, Button, darken, DialogActions, IconButton, Table, TableBody, TableCell, TableHead, TableRow, TextField, Theme, Tooltip, Typography, useTheme } from "@mui/material";
import { green, grey, orange, red, yellow } from "@mui/material/colors";
import { makeStyles } from "@mui/styles";
import ResponsiveDialog from "common/ResponsiveDialog";
import HumidityIcon from "component/HumidityIcon";
import TemperatureIcon from "component/TemperatureIcon";
import { cloneDeep } from "lodash";
import { Bin } from "models";
import { pond } from "protobuf-ts/pond";
import { useGlobalState } from "providers";
import React, { useEffect, useRef, useState } from "react";
import { useMemo } from "react";
import { avg, celsiusToFahrenheit } from "utils";
import BinTableExpanded from "./BinTableExpanded";
import { useMobile } from "hooks";
const useStyles = makeStyles((theme: Theme) => ({
tableStyle: {
borderCollapse: "separate",
borderSpacing: 0, // removes the default gap that "separate" introduces
},
headerCellStyle: {
fontWeight: 650,
backgroundColor: darken(theme.palette.background.paper, 0.3),
textAlign: "center",
padding: 10
},
mobileHeaderCellStyle: {
fontWeight: 650,
backgroundColor: darken(theme.palette.background.paper, 0.3),
textAlign: "center",
padding: 2
},
targetCellStyle: {
textAlign: "center",
backgroundColor: theme.palette.background.paper
},
mobileTargetCellStyle: {
textAlign: "center",
backgroundColor: theme.palette.background.paper,
padding: 2
},
defaultCellStyle: {
padding: 2,
fontSize: 15,
textAlign: "center",
backgroundColor: darken(theme.palette.background.paper, 0.1),
borderTop: "1px solid " + darken(theme.palette.background.paper, 0.4),
borderBottom: "none"
},
colouredCellStyle: {
padding: 2,
borderTop: "1px solid " + darken(theme.palette.background.paper, 0.4),
// fontWeight: 650,
fontSize: 15,
textAlign: "center",
borderBottom: "none"
}
})
);
interface Props {
bin: Bin
updateBinCallback?: (bin: Bin) => void
}
interface BinLevel {
grainTemps: number[]
airTemps: number[]
grainMoistures: number[]
airMoistures: number[]
}
export default function BinTableView(props: Props) {
const classes = useStyles()
const {bin, updateBinCallback} = props
const [{user}] = useGlobalState()
const theme = useTheme()
const [enterTemp, setEnterTemp] = useState(false)
const [enterMoisture, setEnterMoisture] = useState(false)
const [tempTarget, setTempTarget] = useState(0)
const [tempEntry, setTempEntry] = useState("")
const [moistureEntry, setMoistureEntry] = useState("")
const [moistureTarget, setMoistureTarget] = useState(0)
const [openExpandedCables, setOpenExpandedCables] = useState(false)
const isMobile = useMobile()
const inBounds = green[600]
const caution = yellow[800]
const warning = red[700]
const headerRow1Ref = useRef<HTMLTableRowElement>(null)
const [firstRowHeight, setFirstRowHeight] = useState(0)
useEffect(() => {
if (headerRow1Ref.current) {
setFirstRowHeight(headerRow1Ref.current.getBoundingClientRect().height)
}
}, [enterTemp, enterMoisture]) // re-measure when the row height might change due to TextField appearing
useEffect(()=>{
let val = bin.targetTemp()
if(user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT){
val = bin.targetTemp() * (9/5) + 32
}
setTempTarget(val)
setTempEntry(val.toFixed(2))
setMoistureTarget(bin.targetMoisture())
setMoistureEntry(bin.targetMoisture().toFixed(2))
},[bin])
const binLevels = useMemo(() => {
const cables = bin.status.grainCables
let levels: BinLevel[] = []
//note for future self, the first item in the measurement arrays is the lowest node on the cable
//the zones we are creating here will match with the node number because most cables will either have the same number of nodes or only have a difference of 1 or 2
//example: cable 1 has 5 nodes cable 2 has 7 nodes, the first five nodes of the cables will match up and average together for each zone
//and then zones 6 and 7 will be made using only cable 2 because cable 1 doesn't have them
cables.forEach(cable => {
cable.celcius.forEach((c, index) => {
//remember index will be 0 based but the top node starts at 1 for the lowest cable
let inGrain = index < cable.topNode
if (levels[index]){
if(cable.excludedNodes.includes(index+1)) return
if(inGrain){
levels[index].grainTemps.push(c)
//need to check that the cable not only has the moisture mutation on it, but also that it reads humidity
//cables that dont read humidity return an array of 0 values so if the average of all of the values is 0 then it is a temp only cable
if(cable.moisture.length > 0 && avg(cable.moisture) > 0){
//based on all the other checks and the fact that the indexes between the array should match this should never be 0 but put a guard here just in case
//also note that we are only doing moisture and not humidity, it seems like users dont care about humidity
levels[index].grainMoistures.push(cable.moisture[index] ?? 0)
}
}else{
levels[index].airTemps.push(c)
//need to check that the cable not only has the moisture mutation on it, but also that it reads humidity
//cables that dont read humidity return an array of 0 values so if the average of all of the values is 0 then it is a temp only cable
if(cable.moisture.length > 0 && avg(cable.moisture) > 0){
//based on all the other checks and the fact that the indexes between the array should match this should never be 0 but put a guard here just in case
levels[index].airMoistures.push(cable.moisture[index] ?? 0)
}
}
}else{
let newlevel: BinLevel = {
grainTemps: [],
grainMoistures: [],
airTemps: [],
airMoistures: []
}
if(!cable.excludedNodes.includes(index)){
if(inGrain){
newlevel.grainTemps.push(c)
if(cable.moisture.length > 0 && avg(cable.moisture) > 0){
newlevel.grainMoistures.push(cable.moisture[index] ?? 0)
}
}else{
newlevel.airTemps.push(c)
if(cable.moisture.length > 0 && avg(cable.moisture) > 0){
newlevel.airMoistures.push(cable.moisture[index] ?? 0)
}
}
}
levels.push(newlevel)
}
})
})
//flip the levels so that the highest nodes are on the top
levels.reverse()
return levels
},[bin])
const levelDisplay = (level: BinLevel, lastRow?: boolean) => {
let tempDisplay = "--"
let moistureDisplay = "--"
let lvlTemp
let lvlMoisture
let tempColour: string = darken(theme.palette.background.paper, 0.1)
let moistureColour: string = darken(theme.palette.background.paper, 0.1)
//determine the temp
if(level.grainTemps.length > 0){
lvlTemp = avg(level.grainTemps)
//if the average temp is 5 degrees above the bins threshold
if(lvlTemp > (bin.targetTemp() + 10)){
tempColour = warning
}else if(lvlTemp > bin.targetTemp() + 5){
//if the average temp is greater than 5 degrees over
tempColour = caution
}else {
tempColour = inBounds
}
}else if (level.airTemps.length > 0){
lvlTemp = avg(level.airTemps)
}
if (lvlTemp){
if(user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT){
lvlTemp = celsiusToFahrenheit(lvlTemp)
}
tempDisplay = lvlTemp.toFixed(2)
}
//determine the moisture to show
if(level.grainMoistures.length > 0){
lvlMoisture = avg(level.grainMoistures)
if(lvlMoisture > (bin.targetMoisture() + 1.5)){
moistureColour = warning
}else if(lvlMoisture > bin.targetMoisture() + 0.5){
moistureColour = caution
}else {
moistureColour = inBounds
}
}else if (level.airMoistures.length > 0){
lvlMoisture = avg(level.airMoistures)
}
if(lvlMoisture){
moistureDisplay = lvlMoisture.toFixed(2)
}
return (
<React.Fragment>
<TableCell className={classes.colouredCellStyle} padding="none" sx={{backgroundColor: tempColour}}>{tempDisplay}</TableCell>
<TableCell className={classes.colouredCellStyle} padding="none" sx={{backgroundColor: moistureColour, borderBottomRightRadius: lastRow ? 8 : 0}}>{moistureDisplay}</TableCell>
</React.Fragment>
)
}
const update = () => {
let temp = tempTarget
if(user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT){
//convert to c
temp = (temp - 32) * (5 / 9);
}
let clone = cloneDeep(bin)
if(clone.settings.inventory && updateBinCallback){
clone.settings.inventory.targetTemperature = temp
clone.settings.inventory.targetMoisture = moistureTarget
updateBinCallback(clone)
}
}
const tempThreshDisplay = (hideUnit?: boolean) => {
if(user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT){
return tempTarget.toFixed(2) + (hideUnit ? "" : " °F")
}
return tempTarget.toFixed(2) + (hideUnit ? "" : " °C")
}
const LegendSwatch = ({ color }: { color: string }) => (
<Box sx={{ width: 40, height: 20, backgroundColor: color, borderRadius: 1, flexShrink: 0 }} />
)
const updateTempEntry = () => {
return (
<TextField
value={tempEntry}
onChange={e => {
let val = parseFloat(e.target.value)
if(!isNaN(val)){
setTempTarget(val)
}
setTempEntry(e.target.value)
}}
/>
)
}
const updateMoistureEntry = () => {
return (
<TextField
value={moistureEntry}
onChange={e => {
let val = parseFloat(e.target.value)
if(!isNaN(val)){
setMoistureTarget(val)
}
setMoistureEntry(e.target.value)
}}
/>
)
}
const expandedTableDialog = () => {
return (
<ResponsiveDialog open={openExpandedCables} onClose={()=>{setOpenExpandedCables(false)}}>
<BinTableExpanded
cables={bin.status.grainCables}
targetMoisture={bin.targetMoisture()}
targetTemp={bin.targetTemp()}
inBounds={inBounds}
caution={caution}
warning={warning}
/>
<DialogActions>
<Button onClick={()=> {setOpenExpandedCables(false)}}>
Close
</Button>
</DialogActions>
</ResponsiveDialog>
)
}
return (
<Box padding={2} sx={{ display: "flex", flexDirection: "column", height: isMobile ? "88%" : "100%" }}>
{expandedTableDialog()}
{/* Level Table */}
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography fontWeight={650}>Grain Table</Typography>
<Box display="flex" alignItems="center" gap={1}>
<Button
variant="contained"
sx={{ background: theme.palette.background.default, color: theme.palette.text.primary}}
onClick={() => {
setOpenExpandedCables(true)
}}>
Show All Cables
</Button>
<Tooltip title="This table shows the bins levels using nodes that are in the grain averaged together. Data comes from the bins status.
Use the button to see all of the cables individually.">
<Info />
</Tooltip>
</Box>
</Box>
{/* this box is to make the whole table scrollable */}
<Box sx={{ overflowY: "auto", flex: 1, minHeight: 0 }}>
<Table className={classes.tableStyle}>
<TableHead>
<TableRow ref={headerRow1Ref}>
{/* "Targets" cell — hide on mobile when either field is being edited */}
{!(isMobile && (enterTemp || enterMoisture)) && (
<TableCell className={isMobile ? classes.mobileTargetCellStyle : classes.targetCellStyle} sx={{top: 0, position: "sticky", zIndex: 4, borderBottom: "none" }}>
<Box justifyContent={"center"} sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<GppGood />
{!isMobile &&
<Typography fontSize={16} fontWeight={650}>
Targets
</Typography>
}
</Box>
</TableCell>
)}
{/* Temperature cell */}
{!(isMobile && enterMoisture) && (
<TableCell
className={isMobile ? classes.mobileTargetCellStyle : classes.targetCellStyle}
colSpan={isMobile && enterTemp ? 3 : 1}
sx={{ top: 0, position: "sticky", zIndex: 4, borderBottom: "none" }}
>
<Box justifyContent={"center"} sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<TemperatureIcon heightWidth={isMobile ? 25 : 30} />
{enterTemp ? updateTempEntry() : (
<Typography fontSize={isMobile ? 12 : 16}>
{tempThreshDisplay(isMobile)}
</Typography>
)}
{enterTemp ? (
<IconButton onClick={() => { setEnterTemp(false); update(); }}>
<Save />
</IconButton>
) : (
<IconButton onClick={() => setEnterTemp(true)}>
<Edit />
</IconButton>
)}
</Box>
</TableCell>
)}
{/* Moisture cell — hide on mobile when temperature is being edited */}
{!(isMobile && enterTemp) && (
<TableCell
className={isMobile ? classes.mobileTargetCellStyle : classes.targetCellStyle}
colSpan={isMobile && enterMoisture ? 3 : 1}
sx={{ top: 0, position: "sticky", zIndex: 4, borderBottom: "none" }}
>
<Box justifyContent={"center"} sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<HumidityIcon height={isMobile ? 25 : 30} width={20} />
{enterMoisture ? updateMoistureEntry() : (
<Typography fontSize={isMobile ? 12 : 16} sx={{ marginLeft: "10px" }}>
{bin.targetMoisture()}
</Typography>
)}
{enterMoisture ? (
<IconButton onClick={() => { setEnterMoisture(false); update(); }}>
<Save />
</IconButton>
) : (
<IconButton onClick={() => setEnterMoisture(true)}>
<Edit />
</IconButton>
)}
</Box>
</TableCell>
)}
</TableRow>
<TableRow>
<TableCell
className={isMobile ? classes.mobileHeaderCellStyle : classes.headerCellStyle}
sx={{
top: firstRowHeight,
position: "sticky",
zIndex: 3,
borderBottom: "none",
borderTopLeftRadius: 8,
boxShadow: `-12px -12px 0px 4px ${theme.palette.background.paper}`
}}>
Level
</TableCell>
<TableCell
className={isMobile ? classes.mobileHeaderCellStyle : classes.headerCellStyle}
sx={{
top: firstRowHeight,
position: "sticky",
zIndex: 3,
borderBottom: "none"
}}>
{isMobile ? "T" : "Temperature"}{(user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? " (°F)" : " (°C)")}
</TableCell>
<TableCell
className={isMobile ? classes.mobileHeaderCellStyle : classes.headerCellStyle}
sx={{
top: firstRowHeight,
position: "sticky",
zIndex: 3,
borderBottom: "none",
borderTopRightRadius: 8,
// fills the transparent corner area with the target row's background colour
boxShadow: `16px -16px 8px 12px ${theme.palette.background.paper}`
}}>
EMC (%)
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{binLevels.map((level, i) => (
<TableRow key={i}>
<TableCell sx={{borderBottomLeftRadius: (i === binLevels.length-1 ? 8 : 0)}} padding="none" className={classes.defaultCellStyle}>{binLevels.length - i}</TableCell>
{levelDisplay(level, i === binLevels.length-1)}
</TableRow>
))}
</TableBody>
</Table>
</Box>
{/* Table Legend */}
<Box display="flex" alignItems="center" justifyContent="space-between" marginTop={2} sx={{ flexShrink: 0 }}>
<Box display="flex" alignItems="center" gap={1}>
<LegendSwatch color={inBounds} />
<Typography sx={{fontWeight: 600, fontSize: 10}}>On target</Typography>
</Box>
<Box display="flex" alignItems="center" gap={1}>
<LegendSwatch color={caution} />
<Typography sx={{fontWeight: 600, fontSize: 10}}>{user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? "~10 °F" : "+5 °C"} (0.5%) above</Typography>
</Box>
<Box display="flex" alignItems="center" gap={1}>
<LegendSwatch color={warning} />
<Typography sx={{fontWeight: 600, fontSize: 10}}>{user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? "~20 °F" : "+10 °C"} (1.5%) above</Typography>
</Box>
</Box>
</Box>
)
}

View file

@ -19,7 +19,6 @@ import { sameComponentID } from "pbHelpers/Component";
import moment from "moment";
import { lowerCase } from "lodash";
import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
import { fahrenheitToCelsius, getTemperatureUnit } from "utils";
import { GetGrainExtensionMap } from "grain";
import { PromiseProgress, Stage, Step as PromiseStep } from "common/PromiseProgress";
@ -76,7 +75,7 @@ export default function ModeChangeDialog(props: Props){
changeOutdoorHumidity,
presets
} = props
const [{as}] = useGlobalState()
const [{as, user}] = useGlobalState()
const [componentSets, setComponentSets] = useState<ComponentSet[]>([])
const interactionAPI = useInteractionsAPI()
const componentAPI = useComponentAPI();
@ -289,20 +288,24 @@ if (!selectedDevice) return;
describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_PERCENT,
sensor.settings.type,
sensor.settings.subtype
sensor.settings.subtype,
undefined,
user
).toStored(hum)
)
});
conditions.push(humidityCondition);
//since the measurement describers function to stored does a conversion into celsius if the users pref is fahrenheit for temperature we need to convert the preset value into fahrenheit
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
temp = Math.round((temp * (9 / 5) + 32) * 100) / 100;
}
let tempVal = describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
sensor.settings.type,
sensor.settings.subtype
sensor.settings.subtype,
undefined,
user
).toStored(temp);
let tempCondition = pond.InteractionCondition.create({
@ -386,19 +389,23 @@ if (!selectedDevice) return;
value: describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_PERCENT,
sensor.settings.type,
sensor.settings.subtype
sensor.settings.subtype,
undefined,
user
).toStored(humidityPreset)
});
conditions.push(fanConditionOne);
//since the measurement describers function toStored does a conversion into celsius for temperature if the users pref is fahrenheit we need to convert the preset value into fahrenheit
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
tempPreset = Math.round((tempPreset * (9 / 5) + 32) * 100) / 100;
}
let tempVal = describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
sensor.settings.type,
sensor.settings.subtype
sensor.settings.subtype,
undefined,
user
).toStored(tempPreset);
let fanConditionTwo = pond.InteractionCondition.create({
@ -631,7 +638,7 @@ if (!selectedDevice) return;
InputProps={{
endAdornment: (
<InputAdornment position="end">
{getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
{user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
? "°F"
: "℃"}
</InputAdornment>

View file

@ -346,14 +346,14 @@ export default function BinComponentGraph(props: Props) {
? measurements?.map(um =>
areaGraph(
um,
describeMeasurement(um.type, component.type(), component.subType()),
describeMeasurement(um.type, component.type(), component.subType(), undefined, user),
component.settings.smoothingAverages
)
)
: measurements?.map(um =>
lineGraph(
um,
describeMeasurement(um.type, component.type(), component.subType()),
describeMeasurement(um.type, component.type(), component.subType(), undefined, user),
component.settings.smoothingAverages
)
)}

View file

@ -4,6 +4,7 @@ import { GrainCable } from "models/GrainCable";
import { Plenum } from "models/Plenum";
import { UnitMeasurement } from "models/UnitMeasurement";
import moment from "moment";
import { pond } from "protobuf-ts/pond";
import { quack } from "protobuf-ts/quack";
import React, { useEffect, useState } from "react";
import { avg } from "utils";

View file

@ -11,7 +11,6 @@ import { pond } from "protobuf-ts/pond";
import { useBinAPI, useGlobalState } from "providers";
import { useCallback, useEffect, useState } from "react";
import { Legend } from "recharts";
import { getGrainUnit } from "utils";
import BinLevelAreaGraph from "./BinLevelAreaGraph";
interface Props {
@ -32,7 +31,7 @@ interface InventoryAt {
export default function BinLevelOverTime(props: Props) {
const { bin, fertilizerBin, colour, startDate, endDate, customHeight } = props;
const binAPI = useBinAPI();
const [{as}] = useGlobalState();
const [{as, user}] = useGlobalState();
const [inventoryData, setInventoryData] = useState<InventoryAt[]>([]);
const [dataLoading, setDataLoading] = useState(false);
const [capacity, setCapacity] = useState<number | undefined>();
@ -69,10 +68,10 @@ export default function BinLevelOverTime(props: Props) {
if (fertilizerBin && cap) {
cap = cap * 35.239;
}
if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && cap) {
if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && cap) {
cap = cap / bin.bushelsPerTonne();
}
if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && cap) {
if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && cap) {
cap = cap / (bin.bushelsPerTonne()*0.907);
}
setCapacity(cap);
@ -89,10 +88,10 @@ export default function BinLevelOverTime(props: Props) {
let bushels = hist.settings.inventory.grainBushels ?? 0;
if (bushels !== lastBushels) {
let grainDisplay = bushels
if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && bin.bushelsPerTonne() > 1){
if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && bin.bushelsPerTonne() > 1){
grainDisplay = Math.round((bushels / bin.bushelsPerTonne()) * 100) / 100;
}
if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && bin.bushelsPerTonne() > 1){
if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && bin.bushelsPerTonne() > 1){
grainDisplay = Math.round((bushels / (bin.bushelsPerTonne()*0.907)) * 100) / 100;
}
let newData: InventoryAt = {
@ -121,10 +120,10 @@ export default function BinLevelOverTime(props: Props) {
if (data.length === 0) {
let bushels = bin.bushels();
let grainDisplay = bushels
if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && bin.bushelsPerTonne() > 1){
if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && bin.bushelsPerTonne() > 1){
grainDisplay = Math.round((bushels / bin.bushelsPerTonne()) * 100) / 100;
}
if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && bin.bushelsPerTonne() > 1){
if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && bin.bushelsPerTonne() > 1){
grainDisplay = Math.round((bushels / (bin.bushelsPerTonne()*0.907)) * 100) / 100;
}
data.push({
@ -156,10 +155,10 @@ export default function BinLevelOverTime(props: Props) {
if (fertilizerBin && cap) {
cap = cap * 35.239;
}
if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && cap) {
if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && cap) {
cap = cap / bin.bushelsPerTonne();
}
if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && cap) {
if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && cap) {
cap = cap / (bin.bushelsPerTonne()*0.907);
}
setCapacity(cap);
@ -180,10 +179,10 @@ export default function BinLevelOverTime(props: Props) {
if (val.values[0] && m.timestamps[i]) {
if (lastBushels !== val.values[0]) {
let grainDisplay = val.values[0]
if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && bin.bushelsPerTonne() > 1){
if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && bin.bushelsPerTonne() > 1){
grainDisplay = Math.round((grainDisplay / bin.bushelsPerTonne()) * 100) / 100;
}
if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && bin.bushelsPerTonne() > 1){
if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && bin.bushelsPerTonne() > 1){
grainDisplay = Math.round((grainDisplay / (bin.bushelsPerTonne()*0.907)) * 100) / 100;
}
autoBarData.push({
@ -199,10 +198,10 @@ export default function BinLevelOverTime(props: Props) {
let bushels = bin.bushels();
let currentTime = moment().valueOf();
let grainDisplay = bushels
if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && bin.bushelsPerTonne() > 1){
if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && bin.bushelsPerTonne() > 1){
grainDisplay = Math.round((bushels / bin.bushelsPerTonne()) * 100) / 100;
}
if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && bin.bushelsPerTonne() > 1){
if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && bin.bushelsPerTonne() > 1){
grainDisplay = Math.round((bushels / (bin.bushelsPerTonne()*0.907)) * 100) / 100;
}
autoBarData.push({

View file

@ -0,0 +1,292 @@
import { Avatar, Box, Card, CircularProgress, IconButton, Paper, Typography, useTheme } from "@mui/material";
import moment, { Moment } from "moment";
import { Component } from "models";
import { useComponentAPI, useGlobalState } from "providers";
import { useEffect, useMemo, useState } from "react";
import { UnitMeasurement } from "models/UnitMeasurement";
import { quack } from "protobuf-ts/quack";
import {
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
TooltipProps,
XAxis,
YAxis
} from "recharts";
import { avg, roundTo } from "utils";
import { MoreVert } from "@mui/icons-material";
interface Props {
device: number;
icon?: string;
title?: string;
tag?: string;
onMenuClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
component: Component;
startDate: Moment;
endDate: Moment;
customHeight?: string | number;
/**
* this will cause all of the readings for a cable to be used in the average,
* by default it will filter any excluded nodes and only use nodes below the top node
*/
allNodes?: boolean
}
/** Grain-cable nodes to include when averaging (matches GrainCable.filter indexing). */
function valuesForAveraging(
nodeValues: number[],
component: Component,
allNodes?: boolean
): number[] {
if (allNodes) {
return nodeValues.filter(v => !isNaN(v));
}
const grainFilledTo = component.settings.grainFilledTo ?? 0;
const excludedNodes = component.settings.excludedNodes ?? [];
const included: number[] = [];
nodeValues.forEach((v, index) => {
if (isNaN(v)) {
return;
}
if (excludedNodes.includes(index)) {
return;
}
if (grainFilledTo > 0 && index >= grainFilledTo) {
return;
}
included.push(v);
});
return included;
}
interface MeasurementMeta {
value: number;
unit: string;
label: string;
}
interface ChartPoint {
timestamp: number;
_meta: Record<string, MeasurementMeta>;
[seriesKey: string]: number | Record<string, MeasurementMeta> | undefined;
}
interface SeriesInfo {
key: string;
colour: string;
unit: string;
label: string;
}
function normalizeValue(value: number, min: number, max: number): number {
if (max === min) {
return 50;
}
return ((value - min) / (max - min)) * 100;
}
function buildChartData(
measurements: UnitMeasurement[],
component: Component,
allNodes?: boolean
): {
data: ChartPoint[];
series: SeriesInfo[];
} {
const rawSeries = measurements
.filter(um => um.timestamps.length > 0 && um.values.length > 0)
.map(um => {
const points: { timestamp: number; value: number }[] = [];
um.values.forEach((valArray, i) => {
if (valArray.error || !um.timestamps[i]) {
return;
}
const nodeValues = valuesForAveraging(valArray.values, component, allNodes);
if (nodeValues.length === 0) {
return;
}
points.push({
timestamp: moment(um.timestamps[i]).valueOf(),
value: avg(nodeValues)
});
});
const actualValues = points.map(p => p.value);
const min = actualValues.length > 0 ? Math.min(...actualValues) : 0;
const max = actualValues.length > 0 ? Math.max(...actualValues) : 100;
return { um, points, min, max, key: um.label };
})
.filter(s => s.points.length > 0);
const timestampMap = new Map<number, ChartPoint>();
rawSeries.forEach(({ um, points, min, max, key }) => {
points.forEach(({ timestamp, value }) => {
let point = timestampMap.get(timestamp);
if (!point) {
point = { timestamp, _meta: {} };
timestampMap.set(timestamp, point);
}
point[key] = normalizeValue(value, min, max);
point._meta[key] = { value, unit: um.unit, label: um.label };
});
});
const data = Array.from(timestampMap.values()).sort((a, b) => a.timestamp - b.timestamp);
const series = rawSeries.map(({ um, key }) => ({
key,
colour: um.colour,
unit: um.unit,
label: um.label
}));
return { data, series };
}
function SensorGraphTooltip(props: TooltipProps<number, string>) {
const { active, label, payload } = props;
if (!active || !payload?.length) {
return null;
}
return (
<Paper variant="outlined" style={{ opacity: 0.9 }}>
<Box padding={1}>
{payload.map(p => {
const dataKey = String(p.dataKey);
const meta = (p.payload as ChartPoint)._meta?.[dataKey];
return (
<Typography color="textPrimary" variant="subtitle1" key={dataKey}>
{meta?.label ?? dataKey}
{": "}
<Box component="span">
{meta ? `${roundTo(meta.value, 2)} ${meta.unit}` : p.value}
</Box>
</Typography>
);
})}
<Typography color="textSecondary" variant="caption">
{moment(label).format("lll")}
</Typography>
</Box>
</Paper>
);
}
export default function BinSensorGraph(props: Props) {
const { device, icon, title, tag, component, startDate, endDate, customHeight = 350, onMenuClick, allNodes } = props;
const [measurements, setMeasurements] = useState<UnitMeasurement[]>([]);
const [loading, setLoading] = useState(false);
const componentAPI = useComponentAPI();
const [{ user }] = useGlobalState();
const theme = useTheme();
const now = moment();
useEffect(() => {
setLoading(true);
componentAPI
.sampleUnitMeasurements(device, component.key(), startDate, endDate, 1000, undefined, undefined, undefined, undefined, true)
.then(res => {
setMeasurements(res.data.measurements.map(um => UnitMeasurement.any(um, user)));
})
.catch(() => {
setMeasurements([]);
})
.finally(() => {
setLoading(false);
});
}, [device, component, startDate, endDate, componentAPI, user]);
const { data, series } = useMemo(
() => buildChartData(measurements, component, allNodes),
[measurements, component, allNodes]
);
return (
<Card raised sx={{ height: "100%", padding: 1.5 }}>
<Box display="flex" justifyContent="space-between" alignItems="center" marginBottom={1}>
<Box display="flex" alignItems="center" gap={1}>
{icon &&
<Avatar
variant="square"
src={icon}
alt={(title ?? "sensor") + " icon"}
style={{ width: theme.spacing(3), height: theme.spacing(3) }}
/>
}
<Typography fontWeight={500}>{title}</Typography>
{tag && <Typography variant="caption" color="textSecondary">- {tag}</Typography>}
</Box>
{onMenuClick &&
<IconButton size="small" onClick={onMenuClick}>
<MoreVert fontSize="small" />
</IconButton>
}
</Box>
<Box height={customHeight} padding={1}>
{loading ? (
<Box display="flex" justifyContent="center" alignItems="center" height="100%">
<CircularProgress size={80} thickness={1.5} />
</Box>
) : data.length === 0 || series.length === 0 ? (
<Box display="flex" justifyContent="center" alignItems="center" height="100%">
<Typography color="textSecondary">No measurement data</Typography>
</Box>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<XAxis
allowDataOverflow
dataKey="timestamp"
domain={["dataMin", "dataMax"]}
name="Time"
tickFormatter={timestamp => {
const t = moment(timestamp);
return now.isSame(t, "day") ? t.format("LT") : t.format("MMM DD");
}}
scale="time"
type="number"
tick={{ fill: theme.palette.text.primary }}
stroke={theme.palette.divider}
interval="preserveStartEnd"
/>
{/* <YAxis
domain={[0, 100]}
tick={false}
stroke={theme.palette.divider}
label={{
value: "Readings",
position: "insideLeft",
angle: -90,
fill: theme.palette.text.primary
}}
/> */}
<Tooltip
animationEasing="ease-out"
cursor={{ fill: theme.palette.text.primary, opacity: "0.15" }}
content={SensorGraphTooltip}
/>
<Legend verticalAlign="bottom" />
{series.map(s => (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
name={s.label}
stroke={s.colour}
strokeWidth={2}
dot={false}
connectNulls
/>
))}
</LineChart>
</ResponsiveContainer>
)}
</Box>
</Card>
);
}

View file

@ -2,6 +2,7 @@ import { colors } from "@mui/material";
import { jsonBin } from "common/DataImports/BinCables/BinCableImporter";
import { useEffect, useState } from "react";
import { distanceConversion } from "utils";
import { useGlobalState } from "providers";
interface Props {
bin: jsonBin;
@ -22,6 +23,8 @@ export default function SideView(props: Props) {
const dimensionFontSize = 7;
const gapSize = svgViewBoxSize * 0.05;
const { bin } = props;
const [{ user }] = useGlobalState();
const distanceUnit = user.distanceUnit();
const [scale, setScale] = useState(0); //the scale to multiply the bin dimension by in order to determine how long to draw the line
const [hopperHeight, setHopperHeight] = useState(0);
const [roofHeight, setRoofHeight] = useState(0);
@ -194,7 +197,7 @@ export default function SideView(props: Props) {
"bottomText",
svgViewBoxSize / 2,
y2,
distanceConversion(bin.Diameter).toFixed(1)
distanceConversion(bin.Diameter, distanceUnit).toFixed(1)
)
);
@ -231,7 +234,7 @@ export default function SideView(props: Props) {
let y2 = (roofHeight + bin.Sidewall) * scale;
d.push(
dimensionText("sidewallText", x2, midpoint, distanceConversion(bin.Sidewall).toFixed(1))
dimensionText("sidewallText", x2, midpoint, distanceConversion(bin.Sidewall, distanceUnit).toFixed(1))
);
d.push(
dimensionPath(
@ -270,7 +273,7 @@ export default function SideView(props: Props) {
"peakText",
xText,
midpoint,
distanceConversion(bin.Sidewall + roofHeight).toFixed(1)
distanceConversion(bin.Sidewall + roofHeight, distanceUnit).toFixed(1)
)
);
d.push(
@ -313,7 +316,7 @@ export default function SideView(props: Props) {
"totalText",
x2,
midpoint,
distanceConversion(roofHeight + bin.Sidewall + hopperHeight).toFixed(1)
distanceConversion(roofHeight + bin.Sidewall + hopperHeight, distanceUnit).toFixed(1)
)
);
d.push(

View file

@ -18,7 +18,7 @@ import {
//import MaterialTable from "material-table";
//import { getTableIcons } from "common/ResponsiveTable";
import CableDisplay from "./cableDisplay";
import { distanceConversion, getDistanceUnit } from "utils";
import { distanceConversion } from "utils";
import { pond } from "protobuf-ts/pond";
import CableQuote from "./cableQuote";
import { useMobile } from "hooks";
@ -33,6 +33,7 @@ import {
} from "common/TrigFunctions";
import ResponsiveTable, { Column } from "common/ResponsiveTable";
import { makeStyles } from "@mui/styles";
import { useGlobalState } from "providers";
const useStyles = makeStyles(() => ({
sliderThumb: {
@ -56,6 +57,7 @@ export default function CableEstimator() {
//the conversion constant to convert cubic feet to bushels
const conversionConstantFT = 0.7786;
const classes = useStyles();
const [{ user }] = useGlobalState();
const [binOptions, setBinOptions] = useState<jsonBin[]>([]);
const [displayedRows, setDisplayedRows] = useState<jsonBin[]>([]);
const [tablePage, setTablePage] = useState(0)
@ -67,21 +69,21 @@ export default function CableEstimator() {
const [quoteOpen, setQuoteOpen] = useState(false);
const [useCustomBin, setUseCustomBin] = useState(false);
const [diameterFormEntry, setDiameterFormEntry] = useState(
getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS ? "9.1" : "30"
user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS ? "9.1" : "30"
);
//const [capacityFormEntry, setCapacityFormEntry] = useState("0");
const [ringsFormEntry, setRingsFormEntry] = useState("0");
const [ringHeightFormEntry, setRingHeightFormEntry] = useState("0");
const [ringHeightFt, setRingHeightFt] = useState(0);
const [sidewallFormEntry, setSidewallFormEntry] = useState(
getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS ? "7" : "23"
user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS ? "7" : "23"
);
const [peakFormEntry, setPeakFormEntry] = useState(
getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS ? "9.1" : "30"
user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS ? "9.1" : "30"
);
//const [eaveToPeakFormEntry, setEaveToPeakFormEntry] = useState("0");
const [roofAngleFormEntry, setRoofAngleFormEntry] = useState(
getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS ? "7.6" : "25"
user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS ? "7.6" : "25"
);
const [hopperAngleFormEntry, setHopperAngleFormEntry] = useState("0");
const nodeSpacing = 4;
@ -129,24 +131,40 @@ export default function CableEstimator() {
render: (row: jsonBin) => <Box padding={2}>{row.HopperAngle}</Box>
},
{
title: "Diameter " + (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "(ft)" : "(m)"),
title: "Diameter " + (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "(ft)" : "(m)"),
sortKey: "Diameter",
render: (row: jsonBin) => <Box padding={2}>{distanceConversion(row.Diameter).toFixed(2)}</Box>
render: (row: jsonBin) => (
<Box padding={2}>
{distanceConversion(row.Diameter, user.distanceUnit()).toFixed(2)}
</Box>
)
},
{
title: "Sidewall" + (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "(ft)" : "(m)"),
title: "Sidewall" + (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "(ft)" : "(m)"),
sortKey: "Sidewall",
render: (row: jsonBin) => <Box padding={2}>{distanceConversion(row.Sidewall).toFixed(2)}</Box>
render: (row: jsonBin) => (
<Box padding={2}>
{distanceConversion(row.Sidewall, user.distanceUnit()).toFixed(2)}
</Box>
)
},
{
title: "Peak Height" + (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "(ft)" : "(m)"),
title: "Peak Height" + (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "(ft)" : "(m)"),
sortKey: "Peak",
render: (row: jsonBin) => <Box padding={2}>{distanceConversion(row.Peak).toFixed(2)}</Box>
render: (row: jsonBin) => (
<Box padding={2}>
{distanceConversion(row.Peak, user.distanceUnit()).toFixed(2)}
</Box>
)
},
{
title: "Eave To Peak" + (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "(ft)" : "(m)"),
title: "Eave To Peak" + (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "(ft)" : "(m)"),
sortKey: "EaveToPeak",
render: (row: jsonBin) => <Box padding={2}>{distanceConversion(row.EaveToPeak).toFixed(2)}</Box>
render: (row: jsonBin) => (
<Box padding={2}>
{distanceConversion(row.EaveToPeak, user.distanceUnit()).toFixed(2)}
</Box>
)
},
{
title: "Roof Angle",
@ -545,7 +563,7 @@ export default function CableEstimator() {
InputProps={{
endAdornment: (
<InputAdornment position="end">
{getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS ? "m" : "ft"}
{user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS ? "m" : "ft"}
</InputAdornment>
)
}}
@ -554,7 +572,7 @@ export default function CableEstimator() {
onChange={e => {
setDiameterFormEntry(e.target.value);
let val = isNaN(parseFloat(e.target.value)) ? 0 : parseFloat(e.target.value);
if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) {
if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) {
val = val * 3.281;
}
//when the diameter is changed use the current peak height to adjust the roof angle
@ -577,7 +595,7 @@ export default function CableEstimator() {
InputProps={{
endAdornment: (
<InputAdornment position="end">
{getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS ? "m" : "ft"}
{user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS ? "m" : "ft"}
</InputAdornment>
)
}}
@ -589,7 +607,7 @@ export default function CableEstimator() {
onChange={e => {
setPeakFormEntry(e.target.value);
let val = isNaN(parseFloat(e.target.value)) ? 0 : parseFloat(e.target.value);
if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) {
if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) {
val = val * 3.281;
}
//when the peak height is changed use the diameter to calculate the roof angle
@ -621,7 +639,7 @@ export default function CableEstimator() {
//use the new number of rings and ring height to get the sidewall height
let sidewallFt = val * ringHeightFt;
bin.Sidewall = sidewallFt;
if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) {
if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) {
setSidewallFormEntry((sidewallFt / 3.281).toString());
} else {
setSidewallFormEntry(sidewallFt.toString());
@ -650,7 +668,7 @@ export default function CableEstimator() {
onChange={e => {
setRingHeightFormEntry(e.target.value);
let inVal = isNaN(parseFloat(e.target.value)) ? 0 : parseFloat(e.target.value);
// if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) {
// if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) {
// val = val * 3.281;
// }
let ringFt = inVal / 12;
@ -659,7 +677,7 @@ export default function CableEstimator() {
let bin = cloneDeep(customBin);
let sidewallFt = bin.Rings * ringFt;
bin.Sidewall = sidewallFt;
if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) {
if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) {
setSidewallFormEntry((sidewallFt / 3.281).toString());
} else {
setSidewallFormEntry(sidewallFt.toString());
@ -683,7 +701,7 @@ export default function CableEstimator() {
InputProps={{
endAdornment: (
<InputAdornment position="end">
{getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS ? "m" : "ft"}
{user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS ? "m" : "ft"}
</InputAdornment>
)
}}
@ -694,7 +712,7 @@ export default function CableEstimator() {
setSidewallFormEntry(e.target.value);
let val = isNaN(parseFloat(e.target.value)) ? 0 : parseFloat(e.target.value);
//if the value entered is meters convert it to feet for the bin
if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) {
if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) {
val = val * 3.281;
}
let bin = cloneDeep(customBin);
@ -733,7 +751,7 @@ export default function CableEstimator() {
let coneHeight = calcConeHeight(bin.Diameter / 2, bin.RoofAngle);
bin.Peak = bin.Sidewall + coneHeight; //assign it as feet
let peakHeightDisplay = bin.Sidewall + coneHeight;
if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) {
if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) {
peakHeightDisplay = peakHeightDisplay / 3.281;
}
setPeakFormEntry(peakHeightDisplay.toFixed(1));
@ -762,7 +780,7 @@ export default function CableEstimator() {
let coneHeight = calcConeHeight(bin.Diameter / 2, bin.RoofAngle);
bin.Peak = bin.Sidewall + coneHeight;
let peakHeightDisplay = bin.Sidewall + coneHeight;
if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) {
if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) {
peakHeightDisplay = peakHeightDisplay / 3.281;
}
setPeakFormEntry(peakHeightDisplay.toFixed(1));
@ -887,7 +905,7 @@ export default function CableEstimator() {
setEaveToPeakFormEntry(e.target.value);
let val = isNaN(parseFloat(e.target.value)) ? 0 : parseFloat(e.target.value);
//if the value entered is meters convert it to feet for the bin
if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) {
if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) {
val = val * 3.281;
}
let bin = cloneDeep(customBin);

View file

@ -4,8 +4,8 @@ import ResponsiveTable, { Column } from "common/ResponsiveTable";
//import { getTableIcons } from "common/ResponsiveTable";
//import MaterialTable from "material-table";
import { pond } from "protobuf-ts/pond";
import { useEffect } from "react";
import { distanceConversion, getDistanceUnit } from "utils";
import { distanceConversion } from "utils";
import { useGlobalState } from "providers";
interface Props {
bin: jsonBin;
@ -13,6 +13,7 @@ interface Props {
export default function CableMounting(props: Props) {
const { bin } = props;
const [{ user }] = useGlobalState();
// const [tablePage, setTablePage] = useState(0)
// const [pageSize, setPageSize] = useState(10)
@ -26,15 +27,19 @@ export default function CableMounting(props: Props) {
render: (row: CableSum) => <Box padding={2}>{row.Orbit === 0 ? "Center" : row.Orbit}</Box>
},
{
title: "From Center " + (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "(ft)" : "(m)"),
title: "From Center " + (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "(ft)" : "(m)"),
render: (row: CableSum) => (
<Box padding={2}>{distanceConversion(row.DistanceFromCenter).toFixed(2)}</Box>
<Box padding={2}>
{distanceConversion(row.DistanceFromCenter, user.distanceUnit()).toFixed(2)}
</Box>
)
},
{
title: "Along Roof" + (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "(ft)" : "(m)"),
title: "Along Roof" + (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "(ft)" : "(m)"),
render: (row: CableSum) => (
<Box padding={2}>{distanceConversion(roofDist(row.DistanceFromCenter)).toFixed(2)}</Box>
<Box padding={2}>
{distanceConversion(roofDist(row.DistanceFromCenter), user.distanceUnit()).toFixed(2)}
</Box>
)
},
{

View file

@ -5,6 +5,7 @@ import ResponsiveDialog from "common/ResponsiveDialog";
import { useMobile } from "hooks";
import { jsonBin } from "common/DataImports/BinCables/BinCableImporter";
import PdfContent from "./pdfComponents/pdfContent";
import { useGlobalState } from "providers";
interface Props {
bin: jsonBin;
@ -14,6 +15,7 @@ interface Props {
export default function CableQuote(props: Props) {
const { open, close, bin } = props;
const [{user}] = useGlobalState();
const isMobil = useMobile();
const pdfStyle = StyleSheet.create({
viewerDesktop: {
@ -27,7 +29,11 @@ export default function CableQuote(props: Props) {
});
const pdfDoc = () => {
return <AgPDFTemplate content={<PdfContent bin={bin} />} />;
return (
<AgPDFTemplate
content={<PdfContent bin={bin} distanceUnit={user.distanceUnit()} />}
/>
);
};
return (

View file

@ -3,7 +3,8 @@ import { Box, Typography } from "@mui/material";
//import MaterialTable from "material-table";
import { pond } from "protobuf-ts/pond";
import { useEffect, useState } from "react";
import { distanceConversion, getDistanceUnit } from "utils";
import { distanceConversion } from "utils";
import { useGlobalState } from "providers";
import { Cable, jsonBin } from "common/DataImports/BinCables/BinCableImporter";
import { cloneDeep } from "lodash";
import ResponsiveTable, { Column } from "common/ResponsiveTable";
@ -15,17 +16,19 @@ interface Props {
export default function CableTable(props: Props) {
const { bin, /*cablesChanged*/ } = props;
const [{ user }] = useGlobalState();
//const nodeSpacing = 4; //space between the nodes in feet
const [cableData, setCableData] = useState(bin.Cables);
useEffect(() => {
let convertedCables: Cable[] = cloneDeep(bin.Cables);
const du = user.distanceUnit();
convertedCables.forEach(cable => {
cable.Length = Math.round(distanceConversion(cable.Length) * 100) / 100;
cable.Length = Math.round(distanceConversion(cable.Length, du) * 100) / 100;
});
setCableData(convertedCables);
}, [bin]);
}, [bin, user]);
// const table = () => {
// return (
@ -116,7 +119,7 @@ export default function CableTable(props: Props) {
render: (row) => <Box padding={2}>{row.Count}</Box>
},
{
title: "Length" + (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "(ft)" : "(m)"),
title: "Length" + (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "(ft)" : "(m)"),
render: (row) => <Box padding={2}>{row.Length}</Box>
},
{

View file

@ -1,9 +1,9 @@
import { StyleSheet, Text, View } from "@react-pdf/renderer";
import { pond } from "protobuf-ts/pond";
import { getDistanceUnit } from "utils";
interface Props {
borderColour: string;
distanceUnit: pond.DistanceUnit;
}
export default function CableTableHeader(props: Props) {
@ -35,7 +35,8 @@ export default function CableTableHeader(props: Props) {
<Text style={styles.cell}>Type</Text>
<Text style={styles.cell}>Qty</Text>
<Text style={styles.cell}>
Length {getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "(ft)" : "(m)"}
Length{" "}
{props.distanceUnit === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "(ft)" : "(m)"}
</Text>
<Text style={styles.endCell}>Nodes</Text>
</View>

View file

@ -2,10 +2,12 @@ import { StyleSheet, Text, View } from "@react-pdf/renderer";
import { jsonBin } from "common/DataImports/BinCables/BinCableImporter";
import React from "react";
import { distanceConversion } from "utils";
import { pond } from "protobuf-ts/pond";
interface Props {
bin: jsonBin;
borderColour: string;
distanceUnit: pond.DistanceUnit;
}
export default function CableTableRow(props: Props) {
@ -48,7 +50,9 @@ export default function CableTableRow(props: Props) {
<Text style={styles.cell}>{cable.Orbit === 0 ? "Center" : cable.Orbit}</Text>
<Text style={styles.cell}>{cable.Type === 2 ? "Moisture" : "Temperature"}</Text>
<Text style={styles.cell}>{cable.Count}</Text>
<Text style={styles.cell}>{distanceConversion(cable.Length).toFixed(2)}</Text>
<Text style={styles.cell}>
{distanceConversion(cable.Length, props.distanceUnit).toFixed(2)}
</Text>
<Text style={styles.endCell}>{cable.Nodes}</Text>
</View>
);

View file

@ -1,9 +1,9 @@
import { StyleSheet, Text, View } from "@react-pdf/renderer";
import { pond } from "protobuf-ts/pond";
import { getDistanceUnit } from "utils";
interface Props {
borderColour: string;
distanceUnit: pond.DistanceUnit;
}
export default function MountingTableHeader(props: Props) {
@ -43,10 +43,12 @@ export default function MountingTableHeader(props: Props) {
<View style={styles.view}>
<Text style={styles.orbit}>Orbit</Text>
<Text style={styles.centerDist}>
Radius {getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "(ft)" : "(m)"}
Radius{" "}
{props.distanceUnit === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "(ft)" : "(m)"}
</Text>
<Text style={styles.roofDist}>
Along Roof {getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "(ft)" : "(m)"}
Along Roof{" "}
{props.distanceUnit === pond.DistanceUnit.DISTANCE_UNIT_FEET ? "(ft)" : "(m)"}
</Text>
<Text style={styles.bracket}>Bracket</Text>
</View>

View file

@ -2,10 +2,12 @@ import { StyleSheet, Text, View } from "@react-pdf/renderer";
import { getBracket, jsonBin } from "common/DataImports/BinCables/BinCableImporter";
import React from "react";
import { distanceConversion } from "utils";
import { pond } from "protobuf-ts/pond";
interface Props {
bin: jsonBin;
borderColour: string;
distanceUnit: pond.DistanceUnit;
}
export default function MountingTableRow(props: Props) {
@ -65,10 +67,10 @@ export default function MountingTableRow(props: Props) {
key={i}>
<Text style={styles.orbit}>{orbit.Orbit === 0 ? "C" : orbit.Orbit}</Text>
<Text style={styles.centerDist}>
{distanceConversion(orbit.DistanceFromCenter).toFixed(2)}
{distanceConversion(orbit.DistanceFromCenter, props.distanceUnit).toFixed(2)}
</Text>
<Text style={styles.roofDist}>
{distanceConversion(roofDist(orbit.DistanceFromCenter)).toFixed(2)}
{distanceConversion(roofDist(orbit.DistanceFromCenter), props.distanceUnit).toFixed(2)}
</Text>
<Text style={styles.bracket}>
{bracketType}

View file

@ -1,6 +1,7 @@
import { StyleSheet, Text, View } from "@react-pdf/renderer";
import { colors } from "@mui/material";
import { jsonBin } from "common/DataImports/BinCables/BinCableImporter";
import { pond } from "protobuf-ts/pond";
import CableTableHeader from "./cableTable/cableTableHeader";
import CableTableRow from "./cableTable/cableTableRow";
import PDFSideView from "./pdfSVG/pdfSideView";
@ -10,9 +11,11 @@ import MountingTableRow from "./mountingTable/mountingTableRows";
interface Props {
bin: jsonBin;
distanceUnit: pond.DistanceUnit;
}
export default function PdfContent(props: Props) {
const { distanceUnit } = props;
const tableBorderColour = colors.grey[800];
const styles = StyleSheet.create({
@ -53,7 +56,7 @@ export default function PdfContent(props: Props) {
</View>
<View style={{ height: 280, flexDirection: "row" }}>
<View style={{ width: 260 }}>
<PDFSideView bin={props.bin} />
<PDFSideView bin={props.bin} distanceUnit={distanceUnit} />
</View>
<View style={{ width: 260 }}>
<View style={{ height: 195 }}>
@ -63,8 +66,12 @@ export default function PdfContent(props: Props) {
<Text style={{ fontFamily: "Helvetica-Bold", marginBottom: 5 }}>Install Guide</Text>
<View
style={{ borderColor: tableBorderColour, borderWidth: 1, flexDirection: "column" }}>
<MountingTableHeader borderColour={tableBorderColour} />
<MountingTableRow borderColour={tableBorderColour} bin={props.bin} />
<MountingTableHeader borderColour={tableBorderColour} distanceUnit={distanceUnit} />
<MountingTableRow
borderColour={tableBorderColour}
bin={props.bin}
distanceUnit={distanceUnit}
/>
</View>
</View>
</View>
@ -73,8 +80,12 @@ export default function PdfContent(props: Props) {
Recommended Cables
</Text>
<View style={styles.cableTable}>
<CableTableHeader borderColour={tableBorderColour} />
<CableTableRow bin={props.bin} borderColour={tableBorderColour} />
<CableTableHeader borderColour={tableBorderColour} distanceUnit={distanceUnit} />
<CableTableRow
bin={props.bin}
borderColour={tableBorderColour}
distanceUnit={distanceUnit}
/>
</View>
</View>
);

View file

@ -1,11 +1,13 @@
import { colors } from "@mui/material";
import { Circle, Line, Path, Svg, Text } from "@react-pdf/renderer";
import { jsonBin } from "common/DataImports/BinCables/BinCableImporter";
import { pond } from "protobuf-ts/pond";
import { useEffect, useState } from "react";
import { distanceConversion } from "utils";
interface Props {
bin: jsonBin;
distanceUnit: pond.DistanceUnit;
}
interface svgPoint {
@ -22,7 +24,7 @@ export default function PDFSideView(props: Props) {
const nodeSpacing = 4; //space in feet between the nodes
const dimensionFontSize = 18;
const gapSize = svgViewBoxSize * 0.05;
const { bin } = props;
const { bin, distanceUnit } = props;
const [scale, setScale] = useState(0); //the scale to multiply the bin dimension by in order to determine how long to draw the line
const [hopperHeight, setHopperHeight] = useState(0);
const [roofHeight, setRoofHeight] = useState(0);
@ -194,7 +196,7 @@ export default function PDFSideView(props: Props) {
"bottomText",
svgViewBoxSize / 2,
y2,
distanceConversion(bin.Diameter).toFixed(1)
distanceConversion(bin.Diameter, distanceUnit).toFixed(1)
)
);
@ -231,7 +233,12 @@ export default function PDFSideView(props: Props) {
let y2 = (roofHeight + bin.Sidewall) * scale;
d.push(
dimensionText("sidewallText", x2, midpoint, distanceConversion(bin.Sidewall).toFixed(1))
dimensionText(
"sidewallText",
x2,
midpoint,
distanceConversion(bin.Sidewall, distanceUnit).toFixed(1)
)
);
d.push(
dimensionPath(
@ -270,7 +277,7 @@ export default function PDFSideView(props: Props) {
"peakText",
xText,
midpoint,
distanceConversion(bin.Sidewall + roofHeight).toFixed(1)
distanceConversion(bin.Sidewall + roofHeight, distanceUnit).toFixed(1)
)
);
d.push(
@ -313,7 +320,7 @@ export default function PDFSideView(props: Props) {
"totalText",
x2,
midpoint,
distanceConversion(roofHeight + bin.Sidewall + hopperHeight).toFixed(1)
distanceConversion(roofHeight + bin.Sidewall + hopperHeight, distanceUnit).toFixed(1)
)
);
d.push(

View file

@ -67,7 +67,7 @@ export default function MeasurementsChart(props: Props) {
const [currentMax, setCurrentMax] = useState<number | string>("dataMax");
const [newChart, setNewChart] = useState(false);
const [xDomain, setXDomain] = useState<string[] | number[]>(["dataMin", "dataMax"]);
const [{ showErrors }, dispatch] = useGlobalState();
const [{ showErrors, user }, dispatch] = useGlobalState();
const isMobile = useMobile();
// const [zoomStart, setZoomStart] = useState<Moment>();
// const [zoomEnd, setZoomEnd] = useState<Moment>();
@ -123,7 +123,7 @@ export default function MeasurementsChart(props: Props) {
if (filters?.selectedInteractions?.includes(interaction.key())) {
interaction.settings.conditions.forEach((condition, j) => {
if (condition.measurementType === measurementType) {
let describer = describeMeasurement(condition.measurementType);
let describer = describeMeasurement(condition.measurementType, undefined, undefined, undefined, user);
selectedInteractions.push(
<ReferenceLine
key={interaction.key() + ":condition" + j}
@ -197,7 +197,9 @@ export default function MeasurementsChart(props: Props) {
let describer = describeMeasurement(
m.type,
component.settings.type,
component.settings.subtype
component.settings.subtype,
undefined,
user
);
let min: string | number = "dataMin";
let max: string | number = "dataMax";

View file

@ -38,10 +38,12 @@ interface Props {
multiGraphZoom?: (domain: number[] | string[]) => void;
colourAboveZero?: ColourData
colourBelowZero?: ColourData
minY?: string | number
maxY?: string | number
}
export default function SingleSetAreaChart(props: Props) {
const { data, maxRef, minRef, newXDomain, multiGraphZoom, yAxisLabel, colourAboveZero, colourBelowZero, tooltipLabel, tooltipUnit } = props;
const { data, maxRef, minRef, newXDomain, multiGraphZoom, yAxisLabel, colourAboveZero, colourBelowZero, tooltipLabel, tooltipUnit, minY, maxY } = props;
const [xDomain, setXDomain] = useState<string[] | number[]>(["dataMin", "dataMax"]);
const [refLeft, setRefLeft] = useState<number | undefined>();
const [refRight, setRefRight] = useState<number | undefined>();
@ -152,7 +154,8 @@ export default function SingleSetAreaChart(props: Props) {
/>
<YAxis
type="number"
domain={["auto", "auto"]}
allowDataOverflow
domain={[minY ?? "auto", maxY ?? "auto"]}
label={{
value: yAxisLabel,
position: "insideLeft",

View file

@ -21,13 +21,14 @@ const useStyles = makeStyles((_theme: Theme) => {
})
interface Props {
objectKey: string;
parent: string;
parentType: string;
type?: pond.NoteType;
}
export default function Chat(props: Props) {
const [chats, setChats] = useState<pond.Chat[]>([]);
const { objectKey, type } = props;
const { parent, parentType, type } = props;
const noteAPI = useNoteAPI();
const [loaded, setLoaded] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
@ -38,7 +39,7 @@ export default function Chat(props: Props) {
const loadChats = () => {
setLoading(true)
// console.log("listing chats?")
noteAPI.listChats(10, chats.length, "desc", "timestamp", objectKey).then(resp => {
noteAPI.listChats(10, chats.length, "desc", "timestamp", parent, [parent], [parentType]).then(resp => {
setChats(resp.data.chats ? resp.data.chats.reverse().concat(chats) : [])
setTotalMessages(resp.data.total)
}).finally(() => {
@ -81,13 +82,13 @@ export default function Chat(props: Props) {
setLoaded(false);
setLoading(false);
setTotalMessages(0);
}, [objectKey]);
}, [parent]);
useEffect(() => {
if (chats.length === 0 && !loaded) {
loadChats();
}
}, [objectKey, chats, loaded]);
}, [parent, chats, loaded]);
const loadMore = () => {
loadChats();
@ -111,7 +112,7 @@ export default function Chat(props: Props) {
</Box>
<Box style={{ flexShrink: 0 }}>
<Divider />
<ChatInput newNoteMethod={showNewNotes} objectKey={objectKey} type={type} />
<ChatInput newNoteMethod={showNewNotes} parent={parent} type={type} parentType={parentType} />
</Box>
</Box>
);

View file

@ -7,7 +7,7 @@ import Chat from "./Chat";
import { pond } from "protobuf-ts/pond";
import { useEffect, useState } from "react";
import { useTeamAPI } from "providers";
import { closeCrispChat, openCrispChat } from './CrispChat';
import { closeCrispChat, isCrispEnabled, openCrispChat } from './CrispChat';
import RobotIcon from "products/CommonIcons/robotIcon";
const useStyles = makeStyles<Theme>((theme: Theme) => ({
@ -68,7 +68,7 @@ export function ChatDrawer(props: Props) {
}
useEffect(() => {
if (open) {
if (open && isCrispEnabled()) {
closeCrispChat()
}
}, [open])
@ -108,13 +108,15 @@ export function ChatDrawer(props: Props) {
<Avatar src={team.settings.avatar} />
</IconButton>
</Tooltip>
<Tooltip title={"Chat Assistant"} placement="right">
<IconButton
onClick={openCrisp}
>
<RobotIcon />
</IconButton>
</Tooltip>
{isCrispEnabled() && (
<Tooltip title={"Chat Assistant"} placement="right">
<IconButton
onClick={openCrisp}
>
<RobotIcon />
</IconButton>
</Tooltip>
)}
<Box width={"100%"} borderBottom={"1px solid grey"} />
{teams.map((t, i)=> {
if (t.settings?.key === team.key()) return null;
@ -143,7 +145,7 @@ export function ChatDrawer(props: Props) {
<Typography>{selectedTeam.name()} Chat</Typography>
</Box>
<Box className={classes.chatContainer}>
<Chat objectKey={selectedTeam.key()} type={pond.NoteType.NOTE_TYPE_TEAM} />
<Chat parent={selectedTeam.key()} parentType={"team"} type={pond.NoteType.NOTE_TYPE_TEAM} />
</Box>
</Box>
</Box>

View file

@ -40,8 +40,9 @@ const useStyles = makeStyles((theme: Theme) => ({
}))
interface Props {
objectKey: string;
parent: string;
newNoteMethod: (note: Note) => void;
parentType: string; // the object type the note is for ie "bin", "team" etc.
type?: pond.NoteType;
}
@ -52,13 +53,13 @@ interface Props {
export default function ChatInput(props: Props) {
const [message, setMessage] = useState("");
const classes = useStyles();
const { objectKey, type } = props;
const { parent, type, parentType } = props;
const [{ user }] = useGlobalState();
const noteAPI = useNoteAPI();
const { openSnack } = useSnackbar();
const [shift, setShift] = useState(false);
const theme = useTheme();
const [uploadedFiles, setUploadedFiles] = useState<Map<string, string>>(new Map());
// const [uploadedFiles, setUploadedFiles] = useState<Map<string, string>>(new Map());
const clearEntry = () => {
setMessage("");
@ -93,13 +94,13 @@ export default function ChatInput(props: Props) {
}
valid ? submit() : openSnack("Invalid Message");
clearEntry();
setUploadedFiles(new Map());
// setUploadedFiles(new Map());
};
const submit = () => {
if (message !== "" && message !== "\n") {
let newNote = Note.create();
newNote.settings.objectKey = objectKey;
newNote.settings.objectKey = parent;
newNote.settings.userId = user.id();
if (type) {
newNote.settings.objectType = type;
@ -107,13 +108,14 @@ export default function ChatInput(props: Props) {
newNote.settings.timestamp = Date.now();
newNote.settings.content = message;
noteAPI
.addNote(newNote.settings, Array.from(uploadedFiles.keys()))
.addNote(newNote.settings, undefined, [parent], [parentType])
.then(resp => {
newNote.settings.key = resp.data.note
props.newNoteMethod(newNote);
openSnack("Message Sent");
})
.catch(_err => {
console.error(_err)
openSnack("Message Failed to send");
});
}
@ -162,7 +164,7 @@ export default function ChatInput(props: Props) {
</Box>
</Grid>
</Grid>
<Typography>{Array.from(uploadedFiles.values()).toString()}</Typography>
{/* <Typography>{Array.from(uploadedFiles.values()).toString()}</Typography> */}
</React.Fragment>
);
}

View file

@ -24,11 +24,11 @@ export default function ChatOutput(props: Props) {
const ScrollToBottom = () => {
const elementRef = useRef<null | HTMLDivElement>(null);
useEffect(() =>
useEffect(() => {
elementRef?.current?.scrollIntoView({
block: "end"
})
);
});
});
let elem = <div ref={elementRef} />;
return elem;
};

View file

@ -5,6 +5,11 @@ const ANIMATION_DURATION_MS = 300;
let initialized = false;
export function isCrispEnabled(): boolean {
const id = import.meta.env.VITE_CRISP_WEBSITE_ID;
return typeof id === "string" && id.trim() !== "";
}
/**
* Initialize Crisp and immediately hide the default chat button.
* Call this once on app load (e.g., in UserWrapper after user data is ready).
@ -17,6 +22,7 @@ export function initCrisp(opts: {
tokenId?: string;
}) {
if (initialized) return;
if (!opts.websiteId?.trim()) return;
Crisp.configure(opts.websiteId);
Crisp.session.reset();
@ -71,6 +77,7 @@ function injectCrispStyles() {
* Safe to call from any onClick handler.
*/
export function openCrispChat() {
if (!initialized) return;
const chatbox = document.getElementById("crisp-chatbox");
if (chatbox) {
chatbox.classList.add("crisp-visible");
@ -86,6 +93,7 @@ export function openCrispChat() {
* Close the chat window and hide the widget.
*/
export function closeCrispChat() {
if (!initialized) return;
Crisp.chat.close();
hideCrispChatButton();
}

View file

@ -0,0 +1,46 @@
import { Chip, CircularProgress, Tooltip } from "@mui/material";
import { CheckCircleOutline } from "@mui/icons-material";
interface Props {
pending: boolean;
accepted: boolean;
size?: "small" | "medium";
}
/**
* Capsule tag shown alongside the other status chips while changes are
* waiting to be synced to a device or component. Shows a small loading
* circle while pending and a checkmark once the change is accepted.
* Derive the flags with usePendingChanges.
*/
export default function PendingChangesChip(props: Props) {
const { pending, accepted, size } = props;
if (pending) {
return (
<Tooltip title="Your most recent changes will be synced to the device as soon as possible">
<Chip
variant="outlined"
label="Pending changes"
icon={<CircularProgress size={14} thickness={5} color="inherit" />}
size={size}
/>
</Tooltip>
);
}
if (accepted) {
return (
<Tooltip title="The device has accepted your most recent changes">
<Chip
variant="outlined"
label="Settings synced"
icon={<CheckCircleOutline style={{ color: "var(--status-ok)" }} />}
size={size}
/>
</Tooltip>
);
}
return null;
}

Some files were not shown because too many files have changed in this diff Show more