From 63bba4f74ee80d4f0f5ba15b9a1440814dfbcd1c Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 17 Mar 2026 14:46:45 -0600 Subject: [PATCH 001/146] added keys and types to the note api for adding and loading notes and chats --- src/chat/Chat.tsx | 13 ++++++----- src/chat/ChatDrawer.tsx | 2 +- src/chat/ChatInput.tsx | 16 +++++++------ src/harvestPlan/HarvestPlanDisplay.tsx | 2 +- src/maps/mapDrawers/FieldDrawer.tsx | 2 +- src/pages/Bin.tsx | 2 +- src/pages/Contract.tsx | 4 ++-- src/pages/Gate.tsx | 4 ++-- src/pages/Team.tsx | 2 +- src/providers/pond/noteAPI.tsx | 31 +++++++++++++++++++------- src/tasks/TaskDrawer.tsx | 2 +- 11 files changed, 49 insertions(+), 31 deletions(-) diff --git a/src/chat/Chat.tsx b/src/chat/Chat.tsx index 0bfeea5..c9b263b 100644 --- a/src/chat/Chat.tsx +++ b/src/chat/Chat.tsx @@ -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([]); - const { objectKey, type } = props; + const { parent, parentType, type } = props; const noteAPI = useNoteAPI(); const [loaded, setLoaded] = useState(false); const [loading, setLoading] = useState(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) { - + ); diff --git a/src/chat/ChatDrawer.tsx b/src/chat/ChatDrawer.tsx index 8394d69..fa4ae4e 100644 --- a/src/chat/ChatDrawer.tsx +++ b/src/chat/ChatDrawer.tsx @@ -143,7 +143,7 @@ export function ChatDrawer(props: Props) { {selectedTeam.name()} Chat - + diff --git a/src/chat/ChatInput.tsx b/src/chat/ChatInput.tsx index a3753b8..db70fde 100644 --- a/src/chat/ChatInput.tsx +++ b/src/chat/ChatInput.tsx @@ -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>(new Map()); + // const [uploadedFiles, setUploadedFiles] = useState>(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) { - {Array.from(uploadedFiles.values()).toString()} + {/* {Array.from(uploadedFiles.values()).toString()} */} ); } diff --git a/src/harvestPlan/HarvestPlanDisplay.tsx b/src/harvestPlan/HarvestPlanDisplay.tsx index 6917332..5f9721b 100644 --- a/src/harvestPlan/HarvestPlanDisplay.tsx +++ b/src/harvestPlan/HarvestPlanDisplay.tsx @@ -151,7 +151,7 @@ export default function HarvestPlanDisplay(props: Props) { onClose={() => setOpenNote(false)}> Notes - + ); diff --git a/src/maps/mapDrawers/FieldDrawer.tsx b/src/maps/mapDrawers/FieldDrawer.tsx index 724cf5d..ae7a794 100644 --- a/src/maps/mapDrawers/FieldDrawer.tsx +++ b/src/maps/mapDrawers/FieldDrawer.tsx @@ -287,7 +287,7 @@ export default function FieldDrawer(props: Props) { onClose={() => setOpenNote(false)}> Notes - + ); diff --git a/src/pages/Bin.tsx b/src/pages/Bin.tsx index 076dc37..48bba18 100644 --- a/src/pages/Bin.tsx +++ b/src/pages/Bin.tsx @@ -496,7 +496,7 @@ export default function Bin(props: Props) { - + diff --git a/src/pages/Contract.tsx b/src/pages/Contract.tsx index c91f9ea..b14796b 100644 --- a/src/pages/Contract.tsx +++ b/src/pages/Contract.tsx @@ -281,7 +281,7 @@ export default function Contract() { {contract.name()} - Notes - + @@ -316,7 +316,7 @@ export default function Contract() { Notes - + diff --git a/src/pages/Gate.tsx b/src/pages/Gate.tsx index db876e4..c0de27f 100644 --- a/src/pages/Gate.tsx +++ b/src/pages/Gate.tsx @@ -400,7 +400,7 @@ export default function Gate(props: Props) { Notes - + @@ -465,7 +465,7 @@ export default function Gate(props: Props) { {/* tab for notes on mobile and the map drawer */} - + {/* drawer is for displaying notes on desktop */} diff --git a/src/pages/Team.tsx b/src/pages/Team.tsx index 1fa32ba..593b152 100644 --- a/src/pages/Team.tsx +++ b/src/pages/Team.tsx @@ -251,7 +251,7 @@ export default function TeamPage() { - + diff --git a/src/providers/pond/noteAPI.tsx b/src/providers/pond/noteAPI.tsx index 3cd4f91..a53bd52 100644 --- a/src/providers/pond/noteAPI.tsx +++ b/src/providers/pond/noteAPI.tsx @@ -6,7 +6,7 @@ import { createContext, PropsWithChildren, useContext } from "react"; import { pondURL } from "./pond"; export interface INoteAPIContext { - addNote: (note: pond.NoteSettings, attachments?: string[]) => Promise; + addNote: (note: pond.NoteSettings, attachments?: string[], keys?: string[], types?: string[]) => Promise; getNote: (noteID: string) => Promise>; updateNote: (note: pond.NoteSettings) => Promise; listNotes: ( @@ -16,7 +16,8 @@ export interface INoteAPIContext { orderBy?: string, search?: string, asRoot?: boolean, - otherTeam?: string + // otherTeam?: string, + keys?: string[], types?: string[] ) => Promise>; listChats: ( limit: number, @@ -24,8 +25,9 @@ export interface INoteAPIContext { order?: "asc" | "desc", orderBy?: string, search?: string, - asRoot?: boolean, - otherTeam?: string + // asRoot?: boolean, + // otherTeam?: string + keys?: string[], types?: string[] ) => Promise>; removeNote: (noteID: string) => Promise>; } @@ -39,8 +41,13 @@ export default function NoteProvider(props: PropsWithChildren) { const { get, del, post, put } = useHTTP(); // const [{as}] = useGlobalState() - const addNote = (note: pond.NoteSettings, attachments?: string[]) => { - let url = pondURL("/notes" + (attachments ? "?attachments=" + attachments.toString() : "")) + const addNote = (note: pond.NoteSettings, attachments?: string[], keys?: string[], types?: string[]) => { + let url = pondURL( + "/notes" + + // (attachments ? "?attachments=" + attachments.toString() : "") + + (keys ? "?keys=" + keys.join(",") : "") + + (types ? "&types=" + types.join(",") : "") + ) return new Promise((resolve, reject) => { post(url, note).then(resp => { return resolve(resp) @@ -89,6 +96,8 @@ export default function NoteProvider(props: PropsWithChildren) { search?: string, asRoot?: boolean, // otherTeam?: string + keys?: string[], + types?: string[] ) => { // const view = otherTeam ? otherTeam : as let url = pondURL( @@ -100,7 +109,9 @@ export default function NoteProvider(props: PropsWithChildren) { ("&order=" + (order ? order : "asc")) + ("&by=" + (orderBy ? orderBy : "key")) + (search ? "&search=" + search : "") + - (asRoot ? "&asRoot=" + asRoot.toString() : ""), + (asRoot ? "&asRoot=" + asRoot.toString() : "") + + (keys ? "&keys=" + keys.join(",") : "") + + (types ? "&types=" + types.join(",") : "") // (view ? "&as=" + "" : "") ) // console.log(url) @@ -122,6 +133,8 @@ export default function NoteProvider(props: PropsWithChildren) { search?: string, // asRoot?: boolean, // otherTeam?: string + keys?: string[], + types?: string[] ) => { // const view = otherTeam ? otherTeam : as let url = pondURL( @@ -132,7 +145,9 @@ export default function NoteProvider(props: PropsWithChildren) { offset + ("&order=" + (order ? order : "asc")) + ("&by=" + (orderBy ? orderBy : "key")) + - (search ? "&search=" + search : ""), + (search ? "&search=" + search : "") + + (keys ? "&keys=" + keys.join(",") : "") + + (types ? "&types=" + types.join(",") : "") // (asRoot ? "&asRoot=" + asRoot.toString() : ""), // (view ? "&as=" + view : "") ) diff --git a/src/tasks/TaskDrawer.tsx b/src/tasks/TaskDrawer.tsx index 46ffd3a..956a36a 100644 --- a/src/tasks/TaskDrawer.tsx +++ b/src/tasks/TaskDrawer.tsx @@ -194,7 +194,7 @@ export default function TaskDrawer(props: Props) { onClose={() => setOpenNote(false)}> Notes - + ); From 2c28588861c7cdfd164de6cbf3592ba0ea6298fc Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 17 Mar 2026 15:27:05 -0600 Subject: [PATCH 002/146] proto update --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 9b726d6..73097b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11967,7 +11967,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#e4a1e598240d504e3ccfe09d6f023c5865ab2c71", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#9c0f668d4a56b8216dd71a44c3110a818aaf3541", "dependencies": { "protobufjs": "^6.8.8" } From d3a5c24741735a598209b1eb973df6260bba7474 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 18 Mar 2026 14:38:25 -0600 Subject: [PATCH 003/146] updated get permissions to take in the keys and types to get the correct permissions using the context chain --- src/pages/Gate.tsx | 22 ++++++++-------------- src/providers/pond/permissionAPI.tsx | 11 ++++++++--- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/pages/Gate.tsx b/src/pages/Gate.tsx index c0de27f..c32cb84 100644 --- a/src/pages/Gate.tsx +++ b/src/pages/Gate.tsx @@ -3,7 +3,7 @@ import { Gate as IGate } from "models/Gate"; //import { Redirect, useHistory, useRouteMatch } from "react-router"; import React, { useCallback, useEffect, useState } from "react"; import PageContainer from "./PageContainer"; -import { useGlobalState, useGateAPI, useUserAPI } from "providers"; +import { useGlobalState, useGateAPI } from "providers"; import { Box, ButtonBase, @@ -20,7 +20,7 @@ import { } from "@mui/material"; import { pond } from "protobuf-ts/pond"; import DeviceLinkDrawer from "common/DeviceLinkDrawer"; -import { Component, Device, Scope } from "models"; +import { Component, Device } from "models"; import GateActions from "gate/GateActions"; import GateDevice from "gate/GateDevice"; import ObjectControls from "common/ObjectControls"; @@ -28,7 +28,7 @@ import { Link } from "@mui/icons-material"; import Chat from "chat/Chat"; import PlaneIcon from "products/AviationIcons/PlaneIcon"; import NotesIcon from "@mui/icons-material/Notes"; -import { useMobile, useSnackbar, useThemeType } from "hooks"; +import { useMobile, usePermissionAPI, useSnackbar, useThemeType } from "hooks"; import { clone } from "lodash"; import { useNavigate, useParams } from "react-router-dom"; import { makeStyles } from "@mui/styles"; @@ -90,7 +90,7 @@ export default function Gate(props: Props) { const gateID = gateKey ?? useParams<{ gateKey: string }>()?.gateKey ?? ""; const classes = useStyles(); const gateAPI = useGateAPI(); - const userAPI = useUserAPI(); + const permissionAPI = usePermissionAPI(); const [gate, setGate] = useState(IGate.create()); const [devices, setDevices] = useState>( new Map() @@ -115,16 +115,10 @@ export default function Gate(props: Props) { }; useEffect(() => { - let key = gateID; - let kind = "gate"; - if (as) { - key = as; - kind = "team"; - } - userAPI.getUser(user.id(), { key: key, kind: kind } as Scope).then(resp => { - setPermissions(resp.permissions); - }); - }, [as, gateID, userAPI, user]); + permissionAPI.getPermissions(user.id(), [gateID], ["gate"]).then(resp => { + setPermissions(pond.EvaluatePermissionsResponse.fromObject(resp.data).permissions) + }) + }, [as, gateID, permissionAPI, user]); const loadGate = useCallback(() => { let id = gateID; diff --git a/src/providers/pond/permissionAPI.tsx b/src/providers/pond/permissionAPI.tsx index 91b7d6a..1c75827 100644 --- a/src/providers/pond/permissionAPI.tsx +++ b/src/providers/pond/permissionAPI.tsx @@ -8,7 +8,7 @@ import { createContext, PropsWithChildren, useContext } from "react"; import { objectQueryParams, pondURL } from "./pond"; export interface IPermissionAPIContext { - getPermissions: (user: string, scope: Scope) => Promise>; + getPermissions: (user: string, keys: string[], types: string[]) => Promise>; removePermissions: (user: string, scope: Scope) => Promise>; updatePermissions: (scope: Scope, users: User[] | Team[]) => Promise>; updateRelativePermissions: ( @@ -44,9 +44,12 @@ export default function PermissionProvider(props: PropsWithChildren) { const { get, del, put, post } = useHTTP(); const [{ as }] = useGlobalState(); - const getPermissions = (user: string, scope: Scope) => { + const getPermissions = (user: string, keys: string[], types: string[]) => { return new Promise((resolve, reject) => { - get(pondURL("/users/" + user + "/permissions" + objectQueryParams(scope))).then(resp => { + get(pondURL( + "/users/" + user + "/permissions?types=" + types.toString() + "&keys=" + keys.toString() + + (as && "&as=" + as) + )).then(resp => { return resolve(resp) }).catch(err => { return reject(err) @@ -54,6 +57,8 @@ export default function PermissionProvider(props: PropsWithChildren) { }) }; + + const removePermissions = (user: string, scope: Scope) => { return new Promise((resolve, reject) => { del(pondURL("/users/" + user + "/permissions" + objectQueryParams(scope))).then(resp => { From 7e79d0859b18a0feb88f12213c0012f3e91c2536 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Thu, 19 Mar 2026 12:43:51 -0600 Subject: [PATCH 004/146] changed the sample size for the delta temp graph --- src/gate/GateDeltaTempGraph.tsx | 4 ++-- src/gate/GateDeviceInteraction.tsx | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/gate/GateDeltaTempGraph.tsx b/src/gate/GateDeltaTempGraph.tsx index fc5b253..8a6d291 100644 --- a/src/gate/GateDeltaTempGraph.tsx +++ b/src/gate/GateDeltaTempGraph.tsx @@ -109,7 +109,7 @@ export default function GateDeltaTempGraph(props: Props){ tempComponent.key(), start.toISOString(), end.toISOString(), - 500, + 300, 0, "timestamp", ).then(resp => { @@ -123,7 +123,7 @@ export default function GateDeltaTempGraph(props: Props){ ambient.key(), start.toISOString(), end.toISOString(), - 500, + 300, 0, "timestamp", ).then(resp => { diff --git a/src/gate/GateDeviceInteraction.tsx b/src/gate/GateDeviceInteraction.tsx index 0303123..305817a 100644 --- a/src/gate/GateDeviceInteraction.tsx +++ b/src/gate/GateDeviceInteraction.tsx @@ -100,7 +100,6 @@ export default function GateDeviceInteraction(props: Props) { } else if ( gate.preferences[component.key()] === pond.GateComponentType.GATE_COMPONENT_TYPE_PRESSURE ) { - console.log(component) setPressureComponent(component); setPressureSource( quack.ComponentID.create({ From 25973506b1726376fbee9ec9929499e15888078e Mon Sep 17 00:00:00 2001 From: Carter Date: Thu, 19 Mar 2026 12:50:23 -0600 Subject: [PATCH 005/146] temporarily gettin rid of web sockets --- src/hooks/useDeviceStatusStreams.ts | 5 ++++- src/hooks/useWebSocket.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/hooks/useDeviceStatusStreams.ts b/src/hooks/useDeviceStatusStreams.ts index 24ca8bb..5c38058 100644 --- a/src/hooks/useDeviceStatusStreams.ts +++ b/src/hooks/useDeviceStatusStreams.ts @@ -1,6 +1,9 @@ import { pond } from "protobuf-ts/pond"; import { useEffect, useRef, useCallback, useMemo } from "react"; +/** Off by default to reduce server load; set VITE_ENABLE_WEBSOCKETS=true to enable live streams. */ +const WEBSOCKETS_ENABLED = import.meta.env.VITE_ENABLE_WEBSOCKETS === "true"; + /** * Derives the WebSocket base URL from VITE_APP_API_URL. * Matches the logic in useWebSocket.ts. @@ -98,7 +101,7 @@ export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) { ); useEffect(() => { - if (!enabled || !token || deviceIds.length === 0) { + if (!WEBSOCKETS_ENABLED || !enabled || !token || deviceIds.length === 0) { wsMapRef.current.forEach((ws) => ws.close(1000, "disabled")); wsMapRef.current.clear(); reconnectTimeoutsRef.current.forEach((t) => clearTimeout(t)); diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index 6a4029d..ff6092c 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -1,5 +1,8 @@ import { useEffect, useRef, useCallback } from "react"; +/** Off by default to reduce server load; set VITE_ENABLE_WEBSOCKETS=true to enable live streams. */ +const WEBSOCKETS_ENABLED = import.meta.env.VITE_ENABLE_WEBSOCKETS === "true"; + /** * Derives the WebSocket base URL from VITE_APP_API_URL. * e.g. "https://api.brandxtech.ca" -> "wss://api.brandxtech.ca" @@ -100,7 +103,7 @@ export function useWebSocket(options: UseWebSocketOptions) { }, [path, token, rate]); useEffect(() => { - if (!enabled || !token) { + if (!WEBSOCKETS_ENABLED || !enabled || !token) { if (wsRef.current) { wsRef.current.close(1000, "disabled"); wsRef.current = null; From 31f7e8ee074a86bdeadc54586fe60edd5d035c85 Mon Sep 17 00:00:00 2001 From: Carter Date: Fri, 20 Mar 2026 11:16:59 -0600 Subject: [PATCH 006/146] fixed devices page websocket context --- src/hooks/useDeviceStatusStreams.ts | 16 +++++++++--- src/hooks/useWebSocket.ts | 15 ++++++++--- src/pages/Device.tsx | 22 +++++++++++++--- src/pages/Devices.tsx | 40 +++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 10 deletions(-) diff --git a/src/hooks/useDeviceStatusStreams.ts b/src/hooks/useDeviceStatusStreams.ts index 5c38058..e235ebc 100644 --- a/src/hooks/useDeviceStatusStreams.ts +++ b/src/hooks/useDeviceStatusStreams.ts @@ -24,6 +24,12 @@ export interface UseDeviceStatusStreamsOptions { onStatusUpdate: (deviceId: string, status: pond.DeviceStatus) => void; /** Auth token passed as ?token= query param */ token?: string; + /** Same as REST/listDevices (?keys=) — required when listing under a context path or group tab */ + keys?: string[]; + /** Same as REST (?types=) */ + types?: string[]; + /** View-as / impersonation (?as=), same as device list when not already team-first in URL */ + as?: string; /** Whether the connections should be active. Default true. */ enabled?: boolean; } @@ -34,7 +40,7 @@ export interface UseDeviceStatusStreamsOptions { * Readings (plenum, sen5x, co, co2, no2, o2, etc.) stream in real time. */ export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) { - const { deviceIds, onStatusUpdate, token, enabled = true } = options; + const { deviceIds, onStatusUpdate, token, keys, types, as, enabled = true } = options; const onStatusUpdateRef = useRef(onStatusUpdate); onStatusUpdateRef.current = onStatusUpdate; @@ -49,8 +55,12 @@ export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) { const params = new URLSearchParams(); params.set("token", token); + if (keys?.length) params.set("keys", keys.toString()); + if (types?.length) params.set("types", types.toString()); + if (as) params.set("as", as); - const path = `/live/devices/${deviceId}/status`; + // Match useWebSocket / pond routes: /v1/live/devices/:id/status + const path = `/v1/live/devices/${deviceId}/status`; const url = `${getWsBaseUrl()}${path}?${params.toString()}`; const ws = new WebSocket(url); wsMapRef.current.set(deviceId, ws); @@ -91,7 +101,7 @@ export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) { reconnectTimeoutsRef.current.set(deviceId, timeout); }; }, - [token] + [token, keys?.join(), types?.join(), as] ); // Normalize for stable comparison: same set of IDs = same string regardless of order diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index ff6092c..93d3aa2 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -18,7 +18,7 @@ function getWsBaseUrl(): string { } interface UseWebSocketOptions { - /** The API path, e.g. "/v1/live/devices/123/components" */ + /** The API path including /v1, e.g. "/v1/live/devices/123/components" */ path: string; /** Transform the raw MessageEvent into your domain type */ parse: (event: MessageEvent) => T; @@ -28,6 +28,12 @@ interface UseWebSocketOptions { token?: string; /** Minimum seconds between updates, passed as ?rate= to the backend */ rate?: number; + /** Context keys, same as REST device APIs (?keys=) */ + keys?: string[]; + /** Context types, same as REST device APIs (?types=) */ + types?: string[]; + /** Impersonation / team view key, same as REST (?as=) */ + as?: string; /** Whether the connection should be active. Default true. */ enabled?: boolean; } @@ -44,7 +50,7 @@ interface UseWebSocketOptions { * }); */ export function useWebSocket(options: UseWebSocketOptions) { - const { path, parse, onMessage, token, rate = 0, enabled = true } = options; + const { path, parse, onMessage, token, rate = 0, keys, types, as, enabled = true } = options; // Keep latest callbacks in refs so reconnects don't use stale closures const onMessageRef = useRef(onMessage); @@ -62,6 +68,9 @@ export function useWebSocket(options: UseWebSocketOptions) { const params = new URLSearchParams(); params.set("token", token); if (rate > 0) params.set("rate", rate.toString()); + if (keys?.length) params.set("keys", keys.toString()); + if (types?.length) params.set("types", types.toString()); + if (as) params.set("as", as); const url = `${getWsBaseUrl()}${path}?${params.toString()}`; const ws = new WebSocket(url); @@ -100,7 +109,7 @@ export function useWebSocket(options: UseWebSocketOptions) { console.debug(`[ws] reconnecting in ${delay}ms...`); reconnectTimeoutRef.current = setTimeout(connect, delay); }; - }, [path, token, rate]); + }, [path, token, rate, keys?.join(), types?.join(), as]); useEffect(() => { if (!WEBSOCKETS_ENABLED || !enabled || !token) { diff --git a/src/pages/Device.tsx b/src/pages/Device.tsx index db9914e..6dd2623 100644 --- a/src/pages/Device.tsx +++ b/src/pages/Device.tsx @@ -3,7 +3,7 @@ import Grid from '@mui/material/Grid2'; import { Component, Device, Interaction, User } from "models"; import { getContextKeys, getContextTypes } from "pbHelpers/Context"; import { useDeviceAPI, useGlobalState, useSnackbar } from "providers"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useLocation, useParams } from "react-router-dom"; import PageContainer from "./PageContainer"; import { useHTTP, useMobile } from "hooks"; @@ -38,7 +38,7 @@ export default function DevicePage() { const isMobile = useMobile() const deviceID = useParams<{ deviceID: string }>()?.deviceID ?? ""; const groupID = useParams<{ groupID: string }>()?.groupID ?? ""; - const { state } = useLocation(); + const { state, pathname: locationPathname } = useLocation(); const [{ as, team, user }] = useGlobalState() // console.log(state) const [device, setDevice] = useState(state?.device ? Device.create(state.device) : Device.create()) @@ -68,6 +68,14 @@ export default function DevicePage() { const { token } = useHTTP(); + const liveContextKeys = useMemo(() => getContextKeys(), [locationPathname]); + const liveContextTypes = useMemo(() => getContextTypes(), [locationPathname]); + /** Matches getDevicePageData: omit ?as= when first context type is "team". */ + const liveAs = + as && !(liveContextTypes.length > 0 && liveContextTypes[0] === "team") + ? as + : undefined; + const loadDevice = () => { if (loading) return if (state?.devicePageData) { @@ -173,7 +181,7 @@ export default function DevicePage() { // When the backend receives a new reading and updates a component, // this fires and updates the component in state without a page refresh. useWebSocket<{ key: string; component: Component } | null>({ - path: `/live/devices/${deviceID}/components`, + path: `/v1/live/devices/${deviceID}/components`, parse: (e) => { try { const raw = JSON.parse(e.data); @@ -197,6 +205,9 @@ export default function DevicePage() { return updated; }); }, + keys: liveContextKeys, + types: liveContextTypes, + as: liveAs, token, enabled: !!deviceID, }); @@ -204,7 +215,7 @@ export default function DevicePage() { // --- Real-time device updates (optional) --- // Updates device-level info (name, status, lastActive, etc.) useWebSocket({ - path: `/live/devices/${deviceID}`, + path: `/v1/live/devices/${deviceID}`, parse: (e) => { try { const raw = JSON.parse(e.data); @@ -223,6 +234,9 @@ export default function DevicePage() { if (!updatedDevice) return; setDevice(updatedDevice); }, + keys: liveContextKeys, + types: liveContextTypes, + as: liveAs, token, enabled: !!deviceID, }); diff --git a/src/pages/Devices.tsx b/src/pages/Devices.tsx index 0a6b9af..ea31e7f 100644 --- a/src/pages/Devices.tsx +++ b/src/pages/Devices.tsx @@ -264,10 +264,50 @@ export default function Devices() { ) }, []) + /** Same context chain as deviceAPI.list / GetContext on the backend (path + group tab + ?as=). */ + const statusStreamKeys = useMemo(() => { + if (tab !== "all") { + const g = groups[parseInt(tab)] + if (g) { + const keys = [...getContextKeys()] + keys.push(g.id().toString()) + return keys + } + } + return getContextKeys() + }, [tab, groups, location.pathname]) + + const statusStreamTypes = useMemo(() => { + if (tab !== "all") { + const g = groups[parseInt(tab)] + if (g) { + const types = [...getContextTypes()] + types.push("group") + return types + } + } + return getContextTypes() + }, [tab, groups, location.pathname]) + + /** Match Device page / getDevicePageData: omit ?as= when URL context already starts with team. */ + const statusStreamAs = useMemo(() => { + const types = + tab !== "all" && groups[parseInt(tab)] + ? [...getContextTypes(), "group"] + : getContextTypes() + if (as && !(types.length > 0 && types[0] === "team")) { + return as + } + return undefined + }, [as, tab, groups, location.pathname]) + useDeviceStatusStreams({ deviceIds, onStatusUpdate: handleStatusUpdate, token, + keys: statusStreamKeys, + types: statusStreamTypes, + as: statusStreamAs, enabled: devices.length > 0 && !!token, }) From 13e2c180647835dc27c25f6fa0e2121be280b31d Mon Sep 17 00:00:00 2001 From: Carter Date: Fri, 20 Mar 2026 11:34:49 -0600 Subject: [PATCH 007/146] enabled web sockets again to test them on staging --- .env | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env b/.env index 6e8f870..9fdc8f3 100644 --- a/.env +++ b/.env @@ -20,3 +20,6 @@ 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 From 7767110982da5402b84be27025829d1d1fc58946 Mon Sep 17 00:00:00 2001 From: Carter Date: Fri, 20 Mar 2026 11:50:07 -0600 Subject: [PATCH 008/146] fixed duplicate v1 in url --- src/hooks/useDeviceStatusStreams.ts | 14 +------------- src/hooks/useWebSocket.ts | 15 +-------------- 2 files changed, 2 insertions(+), 27 deletions(-) diff --git a/src/hooks/useDeviceStatusStreams.ts b/src/hooks/useDeviceStatusStreams.ts index e235ebc..d04d663 100644 --- a/src/hooks/useDeviceStatusStreams.ts +++ b/src/hooks/useDeviceStatusStreams.ts @@ -1,22 +1,10 @@ import { pond } from "protobuf-ts/pond"; import { useEffect, useRef, useCallback, useMemo } from "react"; +import { getWsBaseUrl } from "utils/getWsBaseUrl"; /** Off by default to reduce server load; set VITE_ENABLE_WEBSOCKETS=true to enable live streams. */ const WEBSOCKETS_ENABLED = import.meta.env.VITE_ENABLE_WEBSOCKETS === "true"; -/** - * Derives the WebSocket base URL from VITE_APP_API_URL. - * Matches the logic in useWebSocket.ts. - */ -function getWsBaseUrl(): string { - const apiUrl = import.meta.env.VITE_APP_API_URL; - if (apiUrl) { - return apiUrl.replace(/^http/, "ws"); - } - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - return `${protocol}//${window.location.host}`; -} - export interface UseDeviceStatusStreamsOptions { /** Device IDs to stream status for (e.g. from the visible devices list) */ deviceIds: string[]; diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index 93d3aa2..9945f2d 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -1,22 +1,9 @@ import { useEffect, useRef, useCallback } from "react"; +import { getWsBaseUrl } from "utils/getWsBaseUrl"; /** Off by default to reduce server load; set VITE_ENABLE_WEBSOCKETS=true to enable live streams. */ const WEBSOCKETS_ENABLED = import.meta.env.VITE_ENABLE_WEBSOCKETS === "true"; -/** - * Derives the WebSocket base URL from VITE_APP_API_URL. - * e.g. "https://api.brandxtech.ca" -> "wss://api.brandxtech.ca" - * Falls back to current page host for local dev. - */ -function getWsBaseUrl(): string { - const apiUrl = import.meta.env.VITE_APP_API_URL; - if (apiUrl) { - return apiUrl.replace(/^http/, "ws"); - } - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - return `${protocol}//${window.location.host}`; -} - interface UseWebSocketOptions { /** The API path including /v1, e.g. "/v1/live/devices/123/components" */ path: string; From f23a5d559dbc8045608593f1a5b02bd71064d91a Mon Sep 17 00:00:00 2001 From: Carter Date: Fri, 20 Mar 2026 11:54:31 -0600 Subject: [PATCH 009/146] added forgotten base url file --- src/utils/getWsBaseUrl.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/utils/getWsBaseUrl.ts diff --git a/src/utils/getWsBaseUrl.ts b/src/utils/getWsBaseUrl.ts new file mode 100644 index 0000000..331b78a --- /dev/null +++ b/src/utils/getWsBaseUrl.ts @@ -0,0 +1,16 @@ +/** + * Origin for Pond WebSocket URLs. + * + * VITE_APP_API_URL usually ends with /v1 (same base axios uses for REST). WS paths are + * written as /v1/live/..., so we strip a trailing /v1 from the env URL to avoid /v1/v1/. + */ +export function getWsBaseUrl(): string { + const apiUrl = import.meta.env.VITE_APP_API_URL; + if (apiUrl) { + let ws = apiUrl.replace(/^http/, "ws"); + ws = ws.replace(/\/v1\/?$/, ""); + return ws.replace(/\/$/, "") || ws; + } + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${window.location.host}`; +} From a901c12ebe73a0682df234e498084323c1131961 Mon Sep 17 00:00:00 2001 From: Carter Date: Fri, 20 Mar 2026 12:22:52 -0600 Subject: [PATCH 010/146] fixed sen5x status card formatting with timestamp --- src/common/StatusDust.tsx | 57 ++++++++++-------- src/common/StatusSen5x.tsx | 116 ++++++++++++++++++++----------------- 2 files changed, 94 insertions(+), 79 deletions(-) diff --git a/src/common/StatusDust.tsx b/src/common/StatusDust.tsx index 4963269..87156e4 100644 --- a/src/common/StatusDust.tsx +++ b/src/common/StatusDust.tsx @@ -23,7 +23,12 @@ const useStyles = makeStyles((theme: Theme) => { marginRight: "auto", margin: theme.spacing(1), width: theme.spacing(16), - height: theme.spacing(11), + minHeight: theme.spacing(11), + }, + readingsWrapper: { + position: "relative", + width: "100%", + minHeight: theme.spacing(10), }, carouselContainer: { position: 'relative', @@ -143,33 +148,35 @@ export default function StatusDust(props: Props) { return ( - - - - - {"<1um:"} {device.status.sen5x?.dust1ug.toFixed(1)}ug/m3 - - - - - {"<2.5um:"} {device.status.sen5x?.dust2ug.toFixed(1)}ug/m3 - - - - - {"<4um: "}{device.status.sen5x?.dust4ug.toFixed(1)}ug/m3 - - - - - {"<10um: "}{device.status.sen5x?.dust10ug.toFixed(1)}ug/m3 - + + + + + + {"<1um:"} {device.status.sen5x?.dust1ug.toFixed(1)}ug/m3 + + + + + {"<2.5um:"} {device.status.sen5x?.dust2ug.toFixed(1)}ug/m3 + + + + + {"<4um: "}{device.status.sen5x?.dust4ug.toFixed(1)}ug/m3 + + + + + {"<10um: "}{device.status.sen5x?.dust10ug.toFixed(1)}ug/m3 + + {showTimestamp && device.status.sen5x?.timestamp && ( - + )} diff --git a/src/common/StatusSen5x.tsx b/src/common/StatusSen5x.tsx index 533fde2..5e1052a 100644 --- a/src/common/StatusSen5x.tsx +++ b/src/common/StatusSen5x.tsx @@ -24,7 +24,13 @@ const useStyles = makeStyles((theme: Theme) => { marginRight: "auto", margin: theme.spacing(1), width: theme.spacing(16), - height: theme.spacing(11), + minHeight: theme.spacing(11), + }, + /** Reserves vertical space so absolute carousel slides do not overlap the timestamp below. */ + readingsWrapper: { + position: "relative", + width: "100%", + minHeight: theme.spacing(10), }, carouselContainer: { position: 'relative', @@ -148,62 +154,64 @@ export default function StatusSen5x(props: Props) { return ( - - - - - Temp: {device.status.sen5x?.temperature.toFixed(1)}°C - - - - - Humidity: {device.status.sen5x?.humidity.toFixed(1)}% - - - - - Voc: {device.status.sen5x?.voc.toFixed(1)}% - - - - - Nox: {device.status.sen5x?.nox.toFixed(1)}% - + + + + + + Temp: {device.status.sen5x?.temperature.toFixed(1)}°C + + + + + Humidity: {device.status.sen5x?.humidity.toFixed(1)}% + + + + + Voc: {device.status.sen5x?.voc.toFixed(1)}% + + + + + Nox: {device.status.sen5x?.nox.toFixed(1)}% + + + + {!noDust && + + + {"<1um:"} {device.status.sen5x?.dust1ug.toFixed(1)}ug/m3 + + + + + {"<2.5um:"} {device.status.sen5x?.dust2ug.toFixed(1)}ug/m3 + + + + + {"<4um: "}{device.status.sen5x?.dust4ug.toFixed(1)}ug/m3 + + + + + {"<10um: "}{device.status.sen5x?.dust10ug.toFixed(1)}ug/m3 + + + } - - {!noDust && - - - {"<1um:"} {device.status.sen5x?.dust1ug.toFixed(1)}ug/m3 - - - - - {"<2.5um:"} {device.status.sen5x?.dust2ug.toFixed(1)}ug/m3 - - - - - {"<4um: "}{device.status.sen5x?.dust4ug.toFixed(1)}ug/m3 - - - - - {"<10um: "}{device.status.sen5x?.dust10ug.toFixed(1)}ug/m3 - - - } {showTimestamp && device.status.sen5x?.timestamp && ( - + )} From 99804a21366e5c4060fcbeee129be0ce152ecd0f Mon Sep 17 00:00:00 2001 From: Carter Date: Fri, 20 Mar 2026 13:08:27 -0600 Subject: [PATCH 011/146] 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 --- src/common/PulseBox.tsx | 76 +++++++++++------- src/common/StatusDust.tsx | 106 +++++++++--------------- src/common/StatusGas.tsx | 17 +--- src/common/StatusPlenum.tsx | 18 +---- src/common/StatusSen5x.tsx | 156 ++++++++++++++++-------------------- 5 files changed, 157 insertions(+), 216 deletions(-) diff --git a/src/common/PulseBox.tsx b/src/common/PulseBox.tsx index 2cdc5ce..37ae123 100644 --- a/src/common/PulseBox.tsx +++ b/src/common/PulseBox.tsx @@ -1,57 +1,79 @@ import { Box, BoxProps, Theme } from "@mui/material"; import { makeStyles } from "@mui/styles"; import classNames from "classnames"; -import { forwardRef, ReactNode } from "react"; +import { forwardRef, ReactNode, useCallback, useState } from "react"; const useStyles = makeStyles((_theme: Theme) => ({ pulseWrapper: { position: 'relative', - display: 'block', // Changed to block to respect Box margins - width: 'fit-content', // Shrinks to child width - '&::after': { - content: '""', - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: '100%', - border: '2px solid var(--pulse-color)', - borderRadius: 'inherit', // Should inherit from Box - animation: '$pulse 1.75s infinite ease-in-out', - pointerEvents: 'none', - boxSizing: 'border-box', - transition: "border-color 0.35s ease-in-out", - }, + display: 'block', + width: 'fit-content', + isolation: 'isolate', + overflow: 'visible', + }, + pulseRing: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + borderRadius: 'inherit', + pointerEvents: 'none', + boxSizing: 'border-box', + zIndex: -1, + animation: '$pulse 4.4s infinite ease-in-out', }, '@keyframes pulse': { - from: { - transform: "scale(1)", - opacity: 1 - }, - to: { - transform: "scale(1.25)", - opacity: 0 - } + /* Start behind box, then expand out and fade */ + /* pulse, pause, pulse, pause - CONSISTENT: both pauses equal, both pulses equal */ + /* Pulse 1 (0-42%) - gradual fade in to soften the start */ + '0%': { boxShadow: '0 0 0 -2px var(--pulse-color)', opacity: 1 }, + '40%': { boxShadow: '0 0 0 14px var(--pulse-color)', opacity: 0 }, + '42%': { boxShadow: '0 0 0 -2px var(--pulse-color)', opacity: 0 }, + /* Pause 1 (42-50%) */ + '50%': { boxShadow: '0 0 0 -2px var(--pulse-color)', opacity: 0 }, + /* Pulse 2 (50-92%) */ + '52%': { boxShadow: '0 0 0 -2px var(--pulse-color)', opacity: 1 }, + '90%': { boxShadow: '0 0 0 14px var(--pulse-color)', opacity: 0 }, + '92%': { boxShadow: '0 0 0 -2px var(--pulse-color)', opacity: 0 }, + /* Pause 2 (92-100%) - same length as Pause 1, color change at iteration */ + '100%': { boxShadow: '0 0 0 -2px var(--pulse-color)', opacity: 0 }, }, })); interface Props extends BoxProps { color: string; + colors?: string[]; children: ReactNode; className?: string; } const PulseBox = forwardRef((props, ref) => { - const { color, children, className, ...boxProps } = props; + const { color, colors: colorsProp, children, className, ...boxProps } = props; const classes = useStyles() + const colors = colorsProp && colorsProp.length > 0 ? colorsProp : [color]; + const [colorIndex, setColorIndex] = useState(0); + const displayColor = colors[colorIndex % colors.length] ?? color; + + const onAnimationIteration = useCallback(() => { + if (colors.length > 1) { + setColorIndex((i) => (i + 1) % colors.length); + } + }, [colors.length]); + return ( + {children} ); diff --git a/src/common/StatusDust.tsx b/src/common/StatusDust.tsx index 87156e4..b45e930 100644 --- a/src/common/StatusDust.tsx +++ b/src/common/StatusDust.tsx @@ -5,7 +5,6 @@ import { Device } from "models"; import PulseBox from "./PulseBox"; import RelativeTimestamp from "./RelativeTimestamp"; import { or } from "utils"; -import { useEffect, useState } from "react"; interface Props { device: Device; @@ -31,68 +30,39 @@ const useStyles = makeStyles((theme: Theme) => { minHeight: theme.spacing(10), }, carouselContainer: { - position: 'relative', - height: '40px', - overflow: 'hidden', + position: "relative", + height: "40px", + overflow: "hidden", textAlign: "center", }, carouselItem: { opacity: 0, - transform: 'translateX(100%)', - transition: 'opacity 0.5s ease, transform 0.5s ease', - position: 'absolute', - width: '100%', + transform: "translateX(100%)", + transition: "opacity 0.5s ease, transform 0.5s ease", + position: "absolute", + width: "100%", overflow: "hidden", - // height: '100%', }, active: { opacity: 1, - transform: 'translateX(0)', + transform: "translateX(0)", }, inactive: { - transform: 'translateX(-100%)', + transform: "translateX(-100%)", }, - }) -}) + }); +}); export default function StatusDust(props: Props) { const { device, showTimestamp } = props; - const classes = useStyles() + const classes = useStyles(); - const colors = or(device.status.sen5x?.overlays.map(overlay => overlay.color), []); - const messages = or(device.status.sen5x?.overlays.map(overlay => overlay.message), []); - - const [color, setColor] = useState(colors.length > 0 ? colors[0] : ""); - const [, setColorIndex] = useState(0) - - useEffect(() => { - const interval = setInterval(() => { - setColorIndex(prevIndex => { - const newIndex = prevIndex >= colors.length - 1 ? 0 : prevIndex + 1; - setColor(colors[newIndex]); // Use the new index here - return newIndex; - }); - }, 7500); - - return () => clearInterval(interval); - }, []); - - // const [currentIndex, setCurrentIndex] = useState(0); - - // Auto-cycle through measurements every 3 seconds - // useEffect(() => { - // const interval = setInterval(() => { - // setCurrentIndex((prevIndex) => (prevIndex + 1) % 2); - // }, 3000); // Adjust timing as needed (3000ms = 3s) - - // return () => clearInterval(interval); // Cleanup on unmount - // }, []); + const colors = or(device.status.sen5x?.overlays.map((overlay) => overlay.color), []); + const messages = or(device.status.sen5x?.overlays.map((overlay) => overlay.message), []); const tooltip = () => { - if (messages.length === 0) return null - if (messages.length < 2) return ( - messages[0] - ) + if (messages.length === 0) return null; + if (messages.length < 2) return messages[0]; return ( @@ -105,10 +75,10 @@ export default function StatusDust(props: Props) { @@ -116,22 +86,20 @@ export default function StatusDust(props: Props) { {message} - ) + ); })} - ) - } + ); + }; function compareTimestamps(timestamp1: string, timestamp2: string): number { const date1 = new Date(timestamp1); const date2 = new Date(timestamp2); - // Check if dates are valid if (isNaN(date1.getTime()) || isNaN(date2.getTime())) { throw new Error("Invalid RFC3339 timestamp format"); } - // Compare using getTime() for millisecond precision return date1.getTime() - date2.getTime(); } @@ -139,38 +107,38 @@ export default function StatusDust(props: Props) { const now = new Date(); const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); if (compareTimestamps(device.status.sen5x?.timestamp, oneDayAgo.toISOString()) < 0) { - return null - } + return null; + } } else { - return null + return null; } return ( - + - + - + {"<1um:"} {device.status.sen5x?.dust1ug.toFixed(1)}ug/m3 - + {"<2.5um:"} {device.status.sen5x?.dust2ug.toFixed(1)}ug/m3 - - {"<4um: "}{device.status.sen5x?.dust4ug.toFixed(1)}ug/m3 + + {"<4um: "} + {device.status.sen5x?.dust4ug.toFixed(1)}ug/m3 - - {"<10um: "}{device.status.sen5x?.dust10ug.toFixed(1)}ug/m3 + + {"<10um: "} + {device.status.sen5x?.dust10ug.toFixed(1)}ug/m3 @@ -183,5 +151,5 @@ export default function StatusDust(props: Props) { - ) -} \ No newline at end of file + ); +} diff --git a/src/common/StatusGas.tsx b/src/common/StatusGas.tsx index b220d96..8c63401 100644 --- a/src/common/StatusGas.tsx +++ b/src/common/StatusGas.tsx @@ -85,23 +85,8 @@ export default function StatusGas(props: Props) { messages = or(device.status[gasType]?.overlays?.map(overlay => overlay.message), []); } - const [color, setColor] = useState(colors.length > 0 ? colors[0] : ""); - const [, setColorIndex] = useState(0) - const gasTypes: ("o2" | "no2" | "co2" | "co" | "lel" | "h2s")[] = ["o2", "no2", "co2", "co", "lel", "h2s"] - useEffect(() => { - const interval = setInterval(() => { - setColorIndex(prevIndex => { - const newIndex = prevIndex >= colors.length - 1 ? 0 : prevIndex + 1; - setColor(colors[newIndex]); // Use the new index here - return newIndex; - }); - }, 7500); - - return () => clearInterval(interval); - }, []); - const [currentIndex, setCurrentIndex] = useState(0); // Auto-cycle through measurements every 5 seconds @@ -183,7 +168,7 @@ export default function StatusGas(props: Props) { } return ( - + { gasType === "all" && diff --git a/src/common/StatusPlenum.tsx b/src/common/StatusPlenum.tsx index 250660a..c7efef0 100644 --- a/src/common/StatusPlenum.tsx +++ b/src/common/StatusPlenum.tsx @@ -5,7 +5,6 @@ import { Device } from "models"; import PulseBox from "./PulseBox"; import RelativeTimestamp from "./RelativeTimestamp"; import { or } from "utils"; -import { useEffect, useState } from "react"; interface Props { device: Device; @@ -33,21 +32,6 @@ export default function StatusPlenum(props: Props) { const colors = or(device.status.plenum?.overlays.map(overlay => overlay.color), []); const messages = or(device.status.plenum?.overlays.map(overlay => overlay.message), []); - const [color, setColor] = useState(colors.length > 0 ? colors[0] : ""); - const [, setColorIndex] = useState(0) - - useEffect(() => { - const interval = setInterval(() => { - setColorIndex(prevIndex => { - const newIndex = prevIndex >= colors.length - 1 ? 0 : prevIndex + 1; - setColor(colors[newIndex]); // Use the new index here - return newIndex; - }); - }, 5000); - - return () => clearInterval(interval); - }, []); - const tooltip = () => { if (messages.length === 0) return null if (messages.length < 2) return ( @@ -107,7 +91,7 @@ export default function StatusPlenum(props: Props) { return ( - + diff --git a/src/common/StatusSen5x.tsx b/src/common/StatusSen5x.tsx index 5e1052a..1c34214 100644 --- a/src/common/StatusSen5x.tsx +++ b/src/common/StatusSen5x.tsx @@ -33,72 +33,52 @@ const useStyles = makeStyles((theme: Theme) => { minHeight: theme.spacing(10), }, carouselContainer: { - position: 'relative', - height: '40px', - overflow: 'hidden', + position: "relative", + height: "40px", + overflow: "hidden", textAlign: "center", }, carouselItem: { opacity: 0, - transform: 'translateX(100%)', - transition: 'opacity 0.5s ease, transform 0.5s ease', - position: 'absolute', - width: '100%', + transform: "translateX(100%)", + transition: "opacity 0.5s ease, transform 0.5s ease", + position: "absolute", + width: "100%", overflow: "hidden", - // height: '100%', }, active: { opacity: 1, - transform: 'translateX(0)', + transform: "translateX(0)", }, inactive: { - transform: 'translateX(-100%)', + transform: "translateX(-100%)", }, - }) -}) + }); +}); export default function StatusSen5x(props: Props) { const { device, noDust, showTimestamp } = props; - const classes = useStyles() - // console.log(noDust) - const colors = or(device.status.sen5x?.overlays.map(overlay => overlay.color), []); - const messages = or(device.status.sen5x?.overlays.map(overlay => overlay.message), []); - - const [color, setColor] = useState(colors.length > 0 ? colors[0] : ""); - const [, setColorIndex] = useState(0) - - useEffect(() => { - const interval = setInterval(() => { - setColorIndex(prevIndex => { - const newIndex = prevIndex >= colors.length - 1 ? 0 : prevIndex + 1; - setColor(colors[newIndex]); // Use the new index here - return newIndex; - }); - }, 7500); - - return () => clearInterval(interval); - }, []); + const classes = useStyles(); + const colors = or(device.status.sen5x?.overlays.map((overlay) => overlay.color), []); + const messages = or(device.status.sen5x?.overlays.map((overlay) => overlay.message), []); const [currentIndex, setCurrentIndex] = useState(0); - // Auto-cycle through measurements every 5 seconds useEffect(() => { if (noDust) { - setCurrentIndex(0) - return + setCurrentIndex(0); + return; } const interval = setInterval(() => { if (!noDust) setCurrentIndex((prevIndex) => (prevIndex + 1) % 2); - }, 5000); + }, 5000); - return () => clearInterval(interval); // Cleanup on unmount + return () => clearInterval(interval); }, [noDust]); const tooltip = () => { - if (messages.length === 0) return null - if (messages.length < 2) return ( - messages[0] - ) + if (messages.length === 0) return null; + if (messages.length < 2) return messages[0]; return ( @@ -111,10 +91,10 @@ export default function StatusSen5x(props: Props) { @@ -122,22 +102,20 @@ export default function StatusSen5x(props: Props) { {message} - ) + ); })} - ) - } + ); + }; function compareTimestamps(timestamp1: string, timestamp2: string): number { const date1 = new Date(timestamp1); const date2 = new Date(timestamp2); - // Check if dates are valid if (isNaN(date1.getTime()) || isNaN(date2.getTime())) { throw new Error("Invalid RFC3339 timestamp format"); } - // Compare using getTime() for millisecond precision return date1.getTime() - date2.getTime(); } @@ -145,70 +123,74 @@ export default function StatusSen5x(props: Props) { const now = new Date(); const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); if (compareTimestamps(device.status.sen5x?.timestamp, oneDayAgo.toISOString()) < 0) { - return null - } + return null; + } } else { - return null + return null; } return ( - + - - + Temp: {device.status.sen5x?.temperature.toFixed(1)}°C - + Humidity: {device.status.sen5x?.humidity.toFixed(1)}% - + Voc: {device.status.sen5x?.voc.toFixed(1)}% - + Nox: {device.status.sen5x?.nox.toFixed(1)}% - {!noDust && - - - {"<1um:"} {device.status.sen5x?.dust1ug.toFixed(1)}ug/m3 - + {!noDust && ( + + + + {"<1um:"} {device.status.sen5x?.dust1ug.toFixed(1)}ug/m3 + + + + + {"<2.5um:"} {device.status.sen5x?.dust2ug.toFixed(1)}ug/m3 + + + + + {"<4um: "} + {device.status.sen5x?.dust4ug.toFixed(1)}ug/m3 + + + + + {"<10um: "} + {device.status.sen5x?.dust10ug.toFixed(1)}ug/m3 + + - - - {"<2.5um:"} {device.status.sen5x?.dust2ug.toFixed(1)}ug/m3 - - - - - {"<4um: "}{device.status.sen5x?.dust4ug.toFixed(1)}ug/m3 - - - - - {"<10um: "}{device.status.sen5x?.dust10ug.toFixed(1)}ug/m3 - - - } + )} {showTimestamp && device.status.sen5x?.timestamp && ( @@ -218,5 +200,5 @@ export default function StatusSen5x(props: Props) { - ) -} \ No newline at end of file + ); +} From 72afd06309a7ae32ea1b6972152bed8401045ded Mon Sep 17 00:00:00 2001 From: Carter Date: Fri, 20 Mar 2026 13:10:32 -0600 Subject: [PATCH 012/146] chore(ui): sync ResponsiveTable from dev_environment Made-with: Cursor --- src/common/ResponsiveTable.tsx | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/common/ResponsiveTable.tsx b/src/common/ResponsiveTable.tsx index b69589e..4c21901 100644 --- a/src/common/ResponsiveTable.tsx +++ b/src/common/ResponsiveTable.tsx @@ -16,8 +16,6 @@ const useStyles = makeStyles((theme: Theme) => { }, tableContainer: { width: "100%", - maxWidth: "100%", - overflow: "hidden", minWidth: 0, }, title: { @@ -550,14 +548,8 @@ export default function ResponsiveTable(props: Props) { if (actualDelta > 0) { // Shrinking current column – give space to next column newWidths[index + 1] = (newWidths[index + 1] ?? 0) + actualDelta - } else if (actualDelta < 0) { - // Growing current column – take from next column only what it can give - const nextWidth = newWidths[index + 1] ?? 0 - const availableFromNext = Math.max(0, nextWidth - MIN_COLUMN_WIDTH) - const takeFromNext = Math.min(-actualDelta, availableFromNext) - newWidths[index + 1] = nextWidth - takeFromNext - // If we couldn't take enough, the table grows (clampedWidth already applied) } + // When growing (actualDelta < 0): never shrink adjacent columns – table grows to accommodate setRowWidths(newWidths) } @@ -605,7 +597,20 @@ export default function ResponsiveTable(props: Props) { )} - +
0 + ? (() => { + const colsWidth = columns.reduce( + (sum, c, i) => sum + (filterList.includes(c.title) || c.hidden ? 0 : (rowWidths[i] ?? 0)), + 0 + ) + (rowSelect ? 48 : 0) + (renderGutter ? 48 : 0) + return colsWidth > 0 ? `max(${colsWidth}px, 100%)` : "100%" + })() + : "100%", + }} + > {customElement && From d6e3fe7a16544d30ca51bf0c938f77074ce2275d Mon Sep 17 00:00:00 2001 From: Carter Date: Mon, 23 Mar 2026 11:05:38 -0600 Subject: [PATCH 013/146] fixed bug causing websockets to reconnect after leaving a page --- src/hooks/useDeviceStatusStreams.ts | 99 ++++++++++++++++++----------- src/hooks/useWebSocket.ts | 23 ++++++- 2 files changed, 84 insertions(+), 38 deletions(-) diff --git a/src/hooks/useDeviceStatusStreams.ts b/src/hooks/useDeviceStatusStreams.ts index d04d663..76d7c68 100644 --- a/src/hooks/useDeviceStatusStreams.ts +++ b/src/hooks/useDeviceStatusStreams.ts @@ -1,5 +1,5 @@ import { pond } from "protobuf-ts/pond"; -import { useEffect, useRef, useCallback, useMemo } from "react"; +import { useEffect, useRef, useMemo } from "react"; import { getWsBaseUrl } from "utils/getWsBaseUrl"; /** Off by default to reduce server load; set VITE_ENABLE_WEBSOCKETS=true to enable live streams. */ @@ -36,10 +36,47 @@ export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) { const wsMapRef = useRef>(new Map()); const retriesRef = useRef>(new Map()); const reconnectTimeoutsRef = useRef>>(new Map()); + /** Bumps only in effect cleanup so late onclose / reconnect timers never run after unmount. */ + const generationRef = useRef(0); + /** Latest ID set so a device dropped from the list does not reconnect on abnormal close. */ + const allowedIdsRef = useRef>(new Set()); + allowedIdsRef.current = new Set(deviceIds); - const connect = useCallback( - (deviceId: string) => { + // Normalize for stable comparison: same set of IDs = same string regardless of order + const deviceIdsKey = useMemo( + () => [...new Set(deviceIds)].sort().join(","), + [deviceIds.join(",")] + ); + + useEffect(() => { + const clearReconnect = (deviceId: string) => { + const t = reconnectTimeoutsRef.current.get(deviceId); + if (t) { + clearTimeout(t); + reconnectTimeoutsRef.current.delete(deviceId); + } + }; + + const shutdownAll = () => { + reconnectTimeoutsRef.current.forEach((t) => clearTimeout(t)); + reconnectTimeoutsRef.current.clear(); + wsMapRef.current.forEach((ws) => ws.close(1000, "cleanup")); + wsMapRef.current.clear(); + retriesRef.current.clear(); + }; + + if (!WEBSOCKETS_ENABLED || !enabled || !token || deviceIds.length === 0) { + shutdownAll(); + return () => { + generationRef.current += 1; + }; + } + + const gen = generationRef.current; + + const connect = (deviceId: string) => { if (!token || !deviceId) return; + if (gen !== generationRef.current) return; const params = new URLSearchParams(); params.set("token", token); @@ -47,18 +84,19 @@ export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) { if (types?.length) params.set("types", types.toString()); if (as) params.set("as", as); - // Match useWebSocket / pond routes: /v1/live/devices/:id/status const path = `/v1/live/devices/${deviceId}/status`; const url = `${getWsBaseUrl()}${path}?${params.toString()}`; const ws = new WebSocket(url); wsMapRef.current.set(deviceId, ws); ws.onopen = () => { + if (gen !== generationRef.current) return; console.debug(`[ws] connected: ${path}`); retriesRef.current.set(deviceId, 0); }; ws.onmessage = (event) => { + if (gen !== generationRef.current) return; try { const raw = JSON.parse(event.data); const status = pond.DeviceStatus.fromObject(raw ?? {}); @@ -75,7 +113,15 @@ export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) { ws.onclose = (event) => { wsMapRef.current.delete(deviceId); - // Don't reconnect on clean close or auth rejection + if (gen !== generationRef.current) { + return; + } + + if (!allowedIdsRef.current.has(deviceId)) { + retriesRef.current.delete(deviceId); + return; + } + if (event.code === 1000 || event.code === 4001 || event.code === 4003) { return; } @@ -85,55 +131,36 @@ export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) { retriesRef.current.set(deviceId, retries + 1); console.debug(`[ws] reconnecting ${path} in ${delay}ms...`); - const timeout = setTimeout(() => connect(deviceId), delay); + const timeout = setTimeout(() => { + if (gen !== generationRef.current) return; + if (!allowedIdsRef.current.has(deviceId)) return; + reconnectTimeoutsRef.current.delete(deviceId); + connect(deviceId); + }, delay); reconnectTimeoutsRef.current.set(deviceId, timeout); }; - }, - [token, keys?.join(), types?.join(), as] - ); - - // Normalize for stable comparison: same set of IDs = same string regardless of order - const deviceIdsKey = useMemo( - () => [...new Set(deviceIds)].sort().join(","), - [deviceIds.join(",")] - ); - - useEffect(() => { - if (!WEBSOCKETS_ENABLED || !enabled || !token || deviceIds.length === 0) { - wsMapRef.current.forEach((ws) => ws.close(1000, "disabled")); - wsMapRef.current.clear(); - reconnectTimeoutsRef.current.forEach((t) => clearTimeout(t)); - reconnectTimeoutsRef.current.clear(); - return; - } + }; const currentIds = new Set(deviceIds); - // Connect to new devices deviceIds.forEach((id) => { if (!wsMapRef.current.has(id)) { connect(id); } }); - // Disconnect from devices no longer in the list wsMapRef.current.forEach((ws, id) => { if (!currentIds.has(id)) { + clearReconnect(id); ws.close(1000, "device removed"); wsMapRef.current.delete(id); - const t = reconnectTimeoutsRef.current.get(id); - if (t) { - clearTimeout(t); - reconnectTimeoutsRef.current.delete(id); - } + retriesRef.current.delete(id); } }); return () => { - wsMapRef.current.forEach((ws) => ws.close(1000, "cleanup")); - wsMapRef.current.clear(); - reconnectTimeoutsRef.current.forEach((t) => clearTimeout(t)); - reconnectTimeoutsRef.current.clear(); + generationRef.current += 1; + shutdownAll(); }; - }, [enabled, token, deviceIdsKey, deviceIds.length, connect]); + }, [enabled, token, deviceIdsKey, deviceIds.length, keys?.join(), types?.join(), as]); } diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index 9945f2d..86f4056 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -48,10 +48,14 @@ export function useWebSocket(options: UseWebSocketOptions) { const wsRef = useRef(null); const reconnectTimeoutRef = useRef>(); const retriesRef = useRef(0); + /** Bumps on each effect cleanup so late onclose / reconnect timers never run after unmount or dependency change. */ + const generationRef = useRef(0); const connect = useCallback(() => { if (!token) return; + const gen = generationRef.current; + const params = new URLSearchParams(); params.set("token", token); if (rate > 0) params.set("rate", rate.toString()); @@ -85,6 +89,10 @@ export function useWebSocket(options: UseWebSocketOptions) { console.debug(`[ws] closed: ${path} (code: ${event.code})`); wsRef.current = null; + if (gen !== generationRef.current) { + return; + } + // Don't reconnect on clean close or auth rejection if (event.code === 1000 || event.code === 4001 || event.code === 4003) { return; @@ -94,22 +102,33 @@ export function useWebSocket(options: UseWebSocketOptions) { const delay = Math.min(1000 * Math.pow(2, retriesRef.current), 30000); retriesRef.current += 1; console.debug(`[ws] reconnecting in ${delay}ms...`); - reconnectTimeoutRef.current = setTimeout(connect, delay); + reconnectTimeoutRef.current = setTimeout(() => { + if (gen !== generationRef.current) return; + connect(); + }, delay); }; }, [path, token, rate, keys?.join(), types?.join(), as]); useEffect(() => { if (!WEBSOCKETS_ENABLED || !enabled || !token) { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } if (wsRef.current) { wsRef.current.close(1000, "disabled"); wsRef.current = null; } - return; + retriesRef.current = 0; + return () => { + generationRef.current += 1; + }; } + retriesRef.current = 0; connect(); return () => { + generationRef.current += 1; if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); } From a288c9503a8bbd48232a571b3866b99dde279d52 Mon Sep 17 00:00:00 2001 From: Carter Date: Mon, 23 Mar 2026 12:18:51 -0600 Subject: [PATCH 014/146] switched to status websocket instead of the dead full device websocket --- src/pages/Device.tsx | 100 ++++++++++++++++++++++++++++++------------- 1 file changed, 70 insertions(+), 30 deletions(-) diff --git a/src/pages/Device.tsx b/src/pages/Device.tsx index 6dd2623..55fd8b9 100644 --- a/src/pages/Device.tsx +++ b/src/pages/Device.tsx @@ -3,7 +3,8 @@ import Grid from '@mui/material/Grid2'; import { Component, Device, Interaction, User } from "models"; import { getContextKeys, getContextTypes } from "pbHelpers/Context"; import { useDeviceAPI, useGlobalState, useSnackbar } from "providers"; -import { useEffect, useMemo, useState } from "react"; +import { useDeviceStatusStreams } from "hooks/useDeviceStatusStreams"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useLocation, useParams } from "react-router-dom"; import PageContainer from "./PageContainer"; import { useHTTP, useMobile } from "hooks"; @@ -23,6 +24,25 @@ import DeviceWizard from "device/DeviceWizard"; import DeviceScannedComponents from "device/autoDetect/DeviceScannedComponents"; import { useWebSocket } from "hooks/useWebSocket"; +type TimestampedReading = { timestamp?: string }; + +function getTimestampMillis(timestamp?: string): number | undefined { + if (!timestamp) return undefined; + const parsed = Date.parse(timestamp); + return Number.isNaN(parsed) ? undefined : parsed; +} + +function pickNewestReading( + current: T | null | undefined, + incoming: T | null | undefined +): T | null | undefined { + const currentTs = getTimestampMillis(current?.timestamp); + const incomingTs = getTimestampMillis(incoming?.timestamp); + if (incomingTs === undefined) return current ?? incoming; + if (currentTs === undefined) return incoming ?? current; + return incomingTs >= currentTs ? incoming : current; +} + export interface DevicePageData { device: Device; components: Component[]; @@ -76,6 +96,55 @@ export default function DevicePage() { ? as : undefined; + const liveStatusDeviceIds = useMemo( + () => (deviceID ? [deviceID] : []), + [deviceID] + ); + + const handleLiveDeviceStatus = useCallback( + (streamDeviceId: string, status: pond.DeviceStatus) => { + setDevice((prev) => { + if (prev.id().toString() !== streamDeviceId) return prev; + const updated = Device.clone(prev); + const incomingStatus = pond.DeviceStatus.fromObject(status); + const currentStatus = prev.status ?? pond.DeviceStatus.create(); + + incomingStatus.plenum = pickNewestReading(currentStatus.plenum, incomingStatus.plenum); + incomingStatus.sen5x = pickNewestReading(currentStatus.sen5x, incomingStatus.sen5x); + incomingStatus.co = pickNewestReading(currentStatus.co, incomingStatus.co); + incomingStatus.co2 = pickNewestReading(currentStatus.co2, incomingStatus.co2); + incomingStatus.no2 = pickNewestReading(currentStatus.no2, incomingStatus.no2); + incomingStatus.o2 = pickNewestReading(currentStatus.o2, incomingStatus.o2); + incomingStatus.lel = pickNewestReading(currentStatus.lel, incomingStatus.lel); + incomingStatus.h2s = pickNewestReading(currentStatus.h2s, incomingStatus.h2s); + + const currentLastActive = getTimestampMillis(currentStatus.lastActive); + const incomingLastActive = getTimestampMillis(incomingStatus.lastActive); + if ( + currentLastActive !== undefined && + incomingLastActive !== undefined && + incomingLastActive < currentLastActive + ) { + incomingStatus.lastActive = currentStatus.lastActive; + } + + updated.status = incomingStatus; + return updated; + }); + }, + [] + ); + + useDeviceStatusStreams({ + deviceIds: liveStatusDeviceIds, + onStatusUpdate: handleLiveDeviceStatus, + token, + keys: liveContextKeys, + types: liveContextTypes, + as: liveAs, + enabled: Boolean(deviceID && token), + }); + const loadDevice = () => { if (loading) return if (state?.devicePageData) { @@ -212,35 +281,6 @@ export default function DevicePage() { enabled: !!deviceID, }); - // --- Real-time device updates (optional) --- - // Updates device-level info (name, status, lastActive, etc.) - useWebSocket({ - path: `/v1/live/devices/${deviceID}`, - parse: (e) => { - try { - const raw = JSON.parse(e.data); - const dev = Device.any(raw); - if (!dev?.settings) { - console.warn("[ws] received device without settings:", raw); - return null; - } - return dev; - } catch (err) { - console.warn("[ws] failed to parse device:", err); - return null; - } - }, - onMessage: (updatedDevice) => { - if (!updatedDevice) return; - setDevice(updatedDevice); - }, - keys: liveContextKeys, - types: liveContextTypes, - as: liveAs, - token, - enabled: !!deviceID, - }); - const loadPortScan = () => { deviceAPI.listFoundComponents(parseInt(deviceID)) .then(resp => { From a2bc841510bd077c446611486cf1a5d2dedf97ec Mon Sep 17 00:00:00 2001 From: csawatzky Date: Mon, 23 Mar 2026 14:57:23 -0600 Subject: [PATCH 015/146] pushing this stuff up so carter can take a look --- src/field/FieldTaskList.tsx | 160 ++++++++++++++++++ src/maps/mapDrawers/FieldDrawer.tsx | 3 +- src/providers/pond/permissionAPI.tsx | 12 +- src/providers/pond/taskAPI.tsx | 30 +++- src/providers/pond/teamAPI.tsx | 10 +- src/tasks/TaskCard.tsx | 140 ++++++++-------- src/tasks/TaskDrawer.tsx | 99 ++++++----- src/tasks/TaskList.tsx | 10 +- src/tasks/TaskSettings.tsx | 9 +- src/tasks/TaskViewer.tsx | 115 +++++++------ src/tasks/taskActions.tsx | 236 +++++++++++++++++++++++++++ src/teams/ObjectTeams.tsx | 10 +- src/teams/ShareWithTeam.tsx | 10 +- 13 files changed, 660 insertions(+), 184 deletions(-) create mode 100644 src/field/FieldTaskList.tsx create mode 100644 src/tasks/taskActions.tsx diff --git a/src/field/FieldTaskList.tsx b/src/field/FieldTaskList.tsx new file mode 100644 index 0000000..3b0d1e3 --- /dev/null +++ b/src/field/FieldTaskList.tsx @@ -0,0 +1,160 @@ +import { Fab, Theme } from "@mui/material" +import { Task } from "models" +import { useGlobalState, useTaskAPI } from "providers" +import React, { useCallback, useEffect, useState } from "react" +import TaskDrawer from "tasks/TaskDrawer" +import TaskList from "tasks/TaskList" +import TaskSettings from "tasks/TaskSettings" +import AddIcon from "@mui/icons-material/Add"; +import { makeStyles } from "@mui/styles"; + + + +interface Props { + field: string +} + +const useStyles = makeStyles((theme: Theme) => { + return ({ + active: { + color: theme.palette.getContrastText(theme.palette.secondary.main), + backgroundColor: theme.palette.secondary.main, + width: theme.spacing(5), + height: theme.spacing(5), + border: 0, + "&:hover": { + backgroundColor: theme.palette.secondary.main + } + }, + fab: { + zIndex: 20, + background: theme.palette.primary.main, + "&:hover": { + background: theme.palette.primary.main + }, + "&:focus": { + background: theme.palette.primary.main + }, + position: "absolute", + bottom: theme.spacing(8), //for mobile navigator + right: theme.spacing(2), + [theme.breakpoints.up("sm")]: { + bottom: theme.spacing(2) + } + }, + overlayFab: { + zIndex: 20, + background: theme.palette.primary.main, + "&:hover": { + background: theme.palette.primary.main + }, + "&:focus": { + background: theme.palette.primary.main + }, + position: "fixed", + bottom: theme.spacing(8), //for mobile navigator + right: theme.spacing(2), + [theme.breakpoints.up("sm")]: { + bottom: theme.spacing(2) + } + } + }); +}); + +export default function FieldTaskList(props: Props) { + const { field } = props + const [{ as }] = useGlobalState() + const taskAPI = useTaskAPI() + const classes = useStyles() + const [selectedTask, setSelectedTask] = useState(undefined) + const [openTaskDrawer, setOpenTaskDrawer] = useState(false) + const [opentaskSettings, setOpenTaskSettings] = useState(false) + const [taskMap, setTaskMap] = useState>(new Map()) + //load the tasks + const loadTasks = useCallback(() => { + taskAPI.listTasks(50, 0, "asc", "start", undefined, undefined, undefined, undefined, undefined, [field], ["field"]).then(resp => { + if(resp.data.tasks){ + let newMap: Map = new Map() + resp.data.tasks.forEach(task => { + newMap.set(task.key, Task.create(task)) + }) + setTaskMap(newMap) + } + }).catch(err => { + + }) + },[as, field]) + + useEffect(()=>{ + loadTasks() + },[loadTasks]) + + const addNewTask = (task: Task) => { + console.log() + } + + const deleteTask = () => { + + } + + const markComplete = () => { + + } + + const updateTask = () => { + + } + //return the task list + return ( + + {selectedTask && + { + setOpenTaskDrawer(false) + setSelectedTask(undefined) + }} + keys={[field]} + types={["field"]} + /> + } + { + let task = taskMap.get(id) + if(task){ + setSelectedTask(task) + setOpenTaskDrawer(true) + } + }} + tasks={Array.from(taskMap.values())} + reLoad={() => { + loadTasks() + }} + keys={[field]} + types={["field"]} + /> + { + setOpenTaskSettings(false) + }} + keys={[field]} + types={["field"]} + task={selectedTask} + /> + {setOpenTaskSettings(true)}} + aria-label="Create Task" + className={classes.overlayFab} + size={"medium"}> + + + + ) +} \ No newline at end of file diff --git a/src/maps/mapDrawers/FieldDrawer.tsx b/src/maps/mapDrawers/FieldDrawer.tsx index ae7a794..f6e864b 100644 --- a/src/maps/mapDrawers/FieldDrawer.tsx +++ b/src/maps/mapDrawers/FieldDrawer.tsx @@ -24,6 +24,7 @@ import { useMobile } from "hooks"; import { useGlobalState, useHarvestPlanAPI, useUserAPI } from "providers"; import { makeStyles } from "@mui/styles"; import FieldActions from "field/FieldActions"; +import FieldTaskList from "field/FieldTaskList"; interface TabPanelProps { children?: React.ReactNode; @@ -273,7 +274,7 @@ export default function FieldDrawer(props: Props) { - + ); diff --git a/src/providers/pond/permissionAPI.tsx b/src/providers/pond/permissionAPI.tsx index 1c75827..ea8e0b9 100644 --- a/src/providers/pond/permissionAPI.tsx +++ b/src/providers/pond/permissionAPI.tsx @@ -25,7 +25,9 @@ export interface IPermissionAPIContext { shareObjectByKey: ( scope: Scope, key: string, - permissions: pond.Permission[] + permissions: pond.Permission[], + keys?: string[], + type?: string[] ) => Promise>; addShareableLink: (scope: Scope, expiration: string) => Promise>; removeShareableLink: (scope: Scope, code: string) => Promise>; @@ -136,9 +138,11 @@ export default function PermissionProvider(props: PropsWithChildren) { }) }; - const shareObjectByKey = (scope: Scope, key: string, permissions: pond.Permission[]) => { - let url = pondURL("/" + scope.kind + "s/" + scope.key + "/shareByKey") - if (as) url = pondURL(`/${scope.kind}s/${scope.key}/shareByKey?as=${as}`) + const shareObjectByKey = (scope: Scope, key: string, permissions: pond.Permission[], keys?: string[], types?: string[]) => { + let url = pondURL("/" + scope.kind + "s/" + scope.key + "/shareByKey") + (keys ? "?keys=" + keys.join(",") : "") + + (types ? "&types=" + types.join(",") : "") + if (as) url = pondURL(`/${scope.kind}s/${scope.key}/shareByKey?as=${as}`) + (keys ? "&keys=" + keys.join(",") : "") + + (types ? "&types=" + types.join(",") : "") return new Promise((resolve, reject) => { post(url, { key: key, diff --git a/src/providers/pond/taskAPI.tsx b/src/providers/pond/taskAPI.tsx index 126f012..087b3d0 100644 --- a/src/providers/pond/taskAPI.tsx +++ b/src/providers/pond/taskAPI.tsx @@ -6,7 +6,7 @@ import { createContext, PropsWithChildren, useContext } from "react"; import { pondURL } from "./pond"; export interface ITaskAPIContext { - addTask: (task: pond.TaskSettings, otherTeam?: string) => Promise>; + addTask: (task: pond.TaskSettings, otherTeam?: string, keys?: string[], types?: string[]) => Promise>; getTask: (taskID: string, otherTeam?: string) => Promise>; listTasks: ( limit: number, @@ -17,7 +17,9 @@ export interface ITaskAPIContext { asRoot?: boolean, otherTeam?: string, from?: string, - to?: string + to?: string, + keys?: string[], + types?: string[] ) => Promise>; removeTask: (taskID: string, otherTeam?: string) => Promise>; updateTask: ( @@ -38,10 +40,20 @@ export default function TaskProvider(props: PropsWithChildren) { const { get, del, post, put } = useHTTP(); const [{ as }] = useGlobalState(); - const addTask = (task: pond.TaskSettings, otherTeam?: string) => { + const addTask = (task: pond.TaskSettings, otherTeam?: string, keys?: string[], types?: string[]) => { const view = otherTeam ? otherTeam : as - if (view) return post(pondURL("/tasks?as=" + view), task); - return post(pondURL("/tasks"), task); + if (view) return post( + pondURL( + "/tasks?as=" + view + + (keys ? "&keys=" + keys.join(",") : "") + + (types ? "&types=" + types.join(",") : "") + ), task); + return post( + pondURL( + "/tasks" + + (keys ? "?keys=" + keys.join(",") : "") + + (types ? "&types=" + types.join(",") : "") + ), task); }; const getTask = (taskID: string, otherTeam?: string) => { @@ -65,7 +77,9 @@ export default function TaskProvider(props: PropsWithChildren) { asRoot?: boolean, otherTeam?: string, from?: string, - to?: string + to?: string, + keys?: string[], + types?: string[] ) => { const view = otherTeam ? otherTeam : as return get( @@ -81,7 +95,9 @@ export default function TaskProvider(props: PropsWithChildren) { (asRoot ? "&asRoot=" + asRoot.toString() : "") + (view ? "&as=" + view : "") + (from ? "&from=" + from : "") + - (to ? "&to=" + to : "") + (to ? "&to=" + to : "") + + (keys ? "&keys=" + keys.join(",") : "") + + (types ? "&types=" + types.join(",") : "") ) ); }; diff --git a/src/providers/pond/teamAPI.tsx b/src/providers/pond/teamAPI.tsx index 32f8a4a..911ee91 100644 --- a/src/providers/pond/teamAPI.tsx +++ b/src/providers/pond/teamAPI.tsx @@ -32,7 +32,7 @@ export interface ITeamAPIContext { prefixSearch?: string, ) => Promise>; listAllTeams: () => Promise>; - listObjectTeams: (scope: Scope, otherTeam?: string) => Promise; + listObjectTeams: (scope: Scope, otherTeam?: string, keys?: string[], types?: string[]) => Promise; removeTeam: (key: string) => Promise>; } @@ -156,10 +156,12 @@ export default function TeamProvider(props: PropsWithChildren) { }) }; - const listObjectTeams = (scope: Scope, otherTeam?: string) => { - let url = "/" + scope.kind + "s/" + scope.key + "/teams" + const listObjectTeams = (scope: Scope, otherTeam?: string, keys?: string[], types?: string[]) => { + let url = "/" + scope.kind + "s/" + scope.key + "/teams" + (keys ? "?keys=" + keys.join(",") : "") + + (types ? "&types=" + types.join(",") : "") const view = otherTeam ? otherTeam : as - if (view) url = `/${scope.kind}s/${scope.key}/teams?as=${view}` + if (view) url = `/${scope.kind}s/${scope.key}/teams?as=${view}` + (keys ? "&keys=" + keys.join(",") : "") + + (types ? "&types=" + types.join(",") : "") return new Promise((resolve, reject) => { get(pondURL(url)).then(resp => { return resolve(resp) diff --git a/src/tasks/TaskCard.tsx b/src/tasks/TaskCard.tsx index bac70e0..281bbb2 100644 --- a/src/tasks/TaskCard.tsx +++ b/src/tasks/TaskCard.tsx @@ -3,25 +3,17 @@ import { Card, CardActionArea, Grid2 as Grid, - IconButton, - ListItemIcon, - ListItemText, - Menu, - MenuItem, Typography } from "@mui/material"; import { makeStyles } from "@mui/styles"; import { useCallback, useEffect, useState } from "react"; import { Task } from "models"; import { useGlobalState } from "providers"; -import { useUserAPI } from "hooks"; -import { Done, MoreVert } from "@mui/icons-material"; -import Edit from "@mui/icons-material/Edit"; -import DeleteIcon from "@mui/icons-material/Delete"; +import { usePermissionAPI } from "hooks"; import { red } from "@mui/material/colors"; import { pond } from "protobuf-ts/pond"; -import { taskScope, teamScope } from "models/Scope"; import EventBlocker from "common/EventBlocker"; +import TaskActions from "./taskActions"; interface Props { task: Task; @@ -30,6 +22,8 @@ interface Props { markComplete: (task: Task) => void; deleteTask: (task: Task) => void; openTaskPage: (taskId: string) => void; + keys: string[] + types: string[] } const useStyles = makeStyles(() => ({ @@ -59,21 +53,14 @@ export default function TaskCard(props: Props) { const classes = useStyles(); const [permissions, setPermissions] = useState([]); const [{ user, as }] = useGlobalState(); - const userAPI = useUserAPI(); - const [menuAnchorEl, setMenuAnchorEl] = useState(null); + const permissionAPI = usePermissionAPI(); const loadPermissions = useCallback(() => { - let scope = taskScope(props.task.key); - if(as){//if viewing as a team use the permissions to the team, and not the task itself - scope = teamScope(as) - } - userAPI - .getUser(user.id(), scope) - .then(resp => { - setPermissions(resp.permissions); - }) - .catch(err => {}); - }, [props.task, userAPI, user, as]); + permissionAPI.getPermissions(user.id(), props.keys, props.types).then(resp => { + setPermissions(pond.EvaluatePermissionsResponse.fromObject(resp.data).permissions) + }) + + }, [props.task, permissionAPI, user, as]); useEffect(() => { loadPermissions(); @@ -89,51 +76,51 @@ export default function TaskCard(props: Props) { setDay(date.getUTCDate()); }, [props.task]); - const taskActions = () => { - return ( - setMenuAnchorEl(null)} - disableAutoFocusItem> - { - props.markComplete(props.task); - setMenuAnchorEl(null); - }}> - - - - - - {permissions.includes(pond.Permission.PERMISSION_WRITE) && ( - { - props.editTaskMethod(props.task); - setMenuAnchorEl(null); - }}> - - - - - - )} - {permissions.includes(pond.Permission.PERMISSION_WRITE) && ( - { - props.deleteTask(props.task); - setMenuAnchorEl(null); - }}> - - - - - - )} - - ); - }; + // const taskActions = () => { + // return ( + // setMenuAnchorEl(null)} + // disableAutoFocusItem> + // { + // props.markComplete(props.task); + // setMenuAnchorEl(null); + // }}> + // + // + // + // + // + // {permissions.includes(pond.Permission.PERMISSION_WRITE) && ( + // { + // props.editTaskMethod(props.task); + // setMenuAnchorEl(null); + // }}> + // + // + // + // + // + // )} + // {permissions.includes(pond.Permission.PERMISSION_WRITE) && ( + // { + // props.deleteTask(props.task); + // setMenuAnchorEl(null); + // }}> + // + // + // + // + // + // )} + // + // ); + // }; return ( @@ -164,18 +151,31 @@ export default function TaskCard(props: Props) { - + {/* setMenuAnchorEl(event.currentTarget)}> + */} + + { + props.reLoad() + }} + keys={props.keys} + types={props.types} + /> - {taskActions()} + {/* {taskActions()} */} ); } diff --git a/src/tasks/TaskDrawer.tsx b/src/tasks/TaskDrawer.tsx index 956a36a..3c206fb 100644 --- a/src/tasks/TaskDrawer.tsx +++ b/src/tasks/TaskDrawer.tsx @@ -22,17 +22,21 @@ import NotesIcon from "@mui/icons-material/Notes"; import DeleteIcon from "@mui/icons-material/Delete"; import Chat from "chat/Chat"; import { pond } from "protobuf-ts/pond"; -import { useMobile, useUserAPI } from "hooks"; +import { useMobile, usePermissionAPI, useUserAPI } from "hooks"; import { getThemeType } from "theme"; import { makeStyles } from "@mui/styles"; +import ObjectTeamsIcon from "@mui/icons-material/SupervisedUserCircle"; +import TaskActions from "./taskActions"; +import { useGlobalState } from "providers"; interface Props { task: Task; open: boolean; closeCallback: () => void; - editTask: (task: Task) => void; deleteTask: (task: Task) => void; completeTask: (task: Task) => void; + keys: string[] + types: string[] } // interface TabPanelProps { @@ -103,17 +107,23 @@ export default function TaskDrawer(props: Props) { "Nov", "Dec" ]; - const { task, open, closeCallback, completeTask, deleteTask, editTask } = props; + const { task, open, closeCallback, completeTask, deleteTask } = props; const [month, setMonth] = useState(0); const [day, setDay] = useState(0); - const [menuAnchorEl, setMenuAnchorEl] = useState(null); + const [permissions, setPermissions] = useState([]) + const [{user}] = useGlobalState(); + // const [menuAnchorEl, setMenuAnchorEl] = useState(null); const [openNote, setOpenNote] = useState(false); const isMobile = useMobile(); const classes = useStyles(); const userAPI = useUserAPI(); + const permissionAPI = usePermissionAPI(); const [worker, setWorker] = useState(); useEffect(() => { + permissionAPI.getPermissions(user.id(), [task.key], ["task"]).then(resp => { + setPermissions(pond.EvaluatePermissionsResponse.fromObject(resp.data).permissions) + }) if (task.start()) { let date = new Date(task.start()); setMonth(date.getUTCMonth()); @@ -122,40 +132,40 @@ export default function TaskDrawer(props: Props) { userAPI.getProfile(task.worker()).then(resp => { setWorker(resp); }); - }, [task, userAPI]); + }, [user, task, userAPI, permissionAPI]); - const taskActions = () => { - return ( - setMenuAnchorEl(null)} - disableAutoFocusItem> - { - editTask(props.task); - setMenuAnchorEl(null); - }}> - - - - - - { - deleteTask(props.task); - setMenuAnchorEl(null); - closeCallback(); - }}> - - - - - - - ); - }; + // const taskActions = () => { + // return ( + // setMenuAnchorEl(null)} + // disableAutoFocusItem> + // { + // editTask(props.task); + // setMenuAnchorEl(null); + // }}> + // + // + // + // + // + // { + // deleteTask(props.task); + // setMenuAnchorEl(null); + // closeCallback(); + // }}> + // + // + // + // + // + // + // ); + // }; const bodyButtons = () => { return ( @@ -175,11 +185,20 @@ export default function TaskDrawer(props: Props) { - setMenuAnchorEl(event.currentTarget)}> - + */} + completeTask(t)} + permissions={permissions} + task={task} + removeTask={deleteTask} + refreshCallback={closeCallback} + keys={props.keys} + types={props.types} + /> @@ -303,7 +322,7 @@ export default function TaskDrawer(props: Props) { {taskTime()} {assignees()} - {taskActions()} + {/* {taskActions()} */} {noteDrawer()} ); diff --git a/src/tasks/TaskList.tsx b/src/tasks/TaskList.tsx index 6803831..8f367ef 100644 --- a/src/tasks/TaskList.tsx +++ b/src/tasks/TaskList.tsx @@ -20,6 +20,8 @@ interface Props { listHeight?: string | number; label?: string; dateToView?: Date; + keys: string[] + types: string[] } export default function TaskList(props: Props) { @@ -31,7 +33,9 @@ export default function TaskList(props: Props) { reLoad, listHeight, label, - dateToView + dateToView, + keys, + types } = props; const [tasks, setTasks] = useState(props.tasks); const [incomplete, setIncomplete] = useState([]); @@ -128,6 +132,8 @@ export default function TaskList(props: Props) { deleteTask={(task: Task) => deleteTask(task)} reLoad={reLoad} openTaskPage={(taskId: string) => openTask(taskId)} + keys={keys} + types={types} /> )); @@ -141,6 +147,8 @@ export default function TaskList(props: Props) { deleteTask={(task: Task) => deleteTask(task)} reLoad={reLoad} openTaskPage={(taskId: string) => openTask(taskId)} + keys={keys} + types={types} /> )); diff --git a/src/tasks/TaskSettings.tsx b/src/tasks/TaskSettings.tsx index 9f92548..dcc6b91 100644 --- a/src/tasks/TaskSettings.tsx +++ b/src/tasks/TaskSettings.tsx @@ -24,14 +24,15 @@ import ResponsiveDialog from "common/ResponsiveDialog"; interface Props { open: boolean; onClose: (reLoad?: boolean) => void; - markComplete?: boolean; startDate?: any; task?: Task; - objectKey?: string; + objectKey?: string; //deprecated, with the new permission structure keys and types will be what determine what a task is connected to type?: string; hasCost?: boolean; costTitle?: string; secondaryCostTitle?: string; + keys?: string[] + types?: string[] } export default function TaskSettings(props: Props) { @@ -104,7 +105,7 @@ export default function TaskSettings(props: Props) { startTime: startTime, end: endDate, endTime: endTime, - objectKey: props.objectKey ? props.objectKey : user.id(), + objectKey: props.objectKey ? props.objectKey : user.id(), //deprecated description: taskDescription, worker: worker, complete: false, @@ -120,7 +121,7 @@ export default function TaskSettings(props: Props) { returnTask.settings = taskSettings; taskAPI - .addTask(taskSettings, as) + .addTask(taskSettings, as, props.keys, props.types) .then(resp => { props.onClose(true); }) diff --git a/src/tasks/TaskViewer.tsx b/src/tasks/TaskViewer.tsx index 12e82d7..00fed3c 100644 --- a/src/tasks/TaskViewer.tsx +++ b/src/tasks/TaskViewer.tsx @@ -26,10 +26,12 @@ import moment from "moment"; import { makeStyles } from "@mui/styles"; interface ViewProps { - objectKey?: string; //only used to save it for the object + // objectKey?: string; //only used to save it for the object --Deprecated with new permission structure-- + keys?: string[]//the keys and types are used to give th object permissions to the task, that is how they will be linked now + types?: string[] label?: string; drawerView?: boolean; - loadKeys?: string[]; //if you want to load tasks for a specific object(s) + // loadKeys?: string[]; //if you want to load tasks for a specific object(s) --Deprecated with new permission structure-- it should use the keys and types now overlayButton?: boolean; } @@ -87,7 +89,15 @@ const useStyles = makeStyles((theme: Theme) => { }); export default function TaskViewer(props: ViewProps) { - const { objectKey, label, drawerView, loadKeys, overlayButton } = props; + const { + //objectKey, + label, + drawerView, + //loadKeys, + keys, + types, + overlayButton + } = props; const [{ user, as }] = useGlobalState(); //const [{ as }] = useGlobalState(); const taskAPI = useTaskAPI(); @@ -143,36 +153,36 @@ export default function TaskViewer(props: ViewProps) { }; - const loadMultitask = useCallback(() => { - if (!loadKeys) return; - if (loadKeys.length > 0) { - let temp = new Map(); - setLoaded(false); - taskAPI - .getMultiTasks(loadKeys, as) - .then(resp => { - if(resp.data.tasks){ - resp.data.tasks.forEach(task => { - if (task.settings) { - temp.set(task.key, Task.any(task)); - } - }); - } - setTasks(temp); - setLoaded(true); - }) - .catch(err => { - openSnack("Failed to load"); - }); - } - }, [loadKeys, openSnack, taskAPI, as]); + // const loadMultitask = useCallback(() => { + // if (!loadKeys) return; + // if (loadKeys.length > 0) { + // let temp = new Map(); + // setLoaded(false); + // taskAPI + // .getMultiTasks(loadKeys, as) + // .then(resp => { + // if(resp.data.tasks){ + // resp.data.tasks.forEach(task => { + // if (task.settings) { + // temp.set(task.key, Task.any(task)); + // } + // }); + // } + // setTasks(temp); + // setLoaded(true); + // }) + // .catch(err => { + // openSnack("Failed to load"); + // }); + // } + // }, [loadKeys, openSnack, taskAPI, as]); //loads tasks from the backend database const loadTasks = useCallback(() => { if (!user.id()) return; let temp = new Map(); taskAPI - .listTasks(50, 0, "asc", "start", undefined, undefined, as, prevMonth, nextMonth) + .listTasks(50, 0, "asc", "start", undefined, undefined, as, prevMonth, nextMonth, keys, types) .then(resp => { if(resp.data.tasks){ resp.data.tasks.forEach(task => { @@ -185,20 +195,20 @@ export default function TaskViewer(props: ViewProps) { setLoaded(true); }) .catch(err => { - console.log(err) openSnack("Failed to load tasks"); }); - }, [taskAPI, as, user, openSnack, nextMonth, prevMonth]); + }, [taskAPI, as, user, openSnack, nextMonth, prevMonth, keys, types]); useEffect(() => { - if (drawerView) { - setTasks(new Map()); - setValue(1); - loadMultitask(); - } else { - loadTasks(); - } - }, [loadTasks, loadMultitask, as, drawerView]); + // if (drawerView) { + // setTasks(new Map()); + // setValue(1); + // loadMultitask(); + // } else { + // loadTasks(); + // } + loadTasks() + }, [loadTasks, as, drawerView]); const markComplete = (task: Task) => { task.settings.complete = !task.settings.complete; @@ -320,6 +330,8 @@ export default function TaskViewer(props: ViewProps) { reLoad={() => loadTasks()} dateToView={drawerView ? undefined : currentDate} openTask={(taskId: string) => openSelectedTask(taskId)} + keys={[]} + types={[]} /> openSelectedTask(taskId)} + keys={[]} + types={[]} /> @@ -423,16 +437,19 @@ export default function TaskViewer(props: ViewProps) { { - if (r) { - if (drawerView) { - loadMultitask(); - } else { - loadTasks(); - } - } + // if (r) { + // if (drawerView) { + // loadMultitask(); + // } else { + // loadTasks(); + // } + // } + loadTasks(); setEditTask(undefined); setNewTaskDialog(false); }} @@ -441,10 +458,14 @@ export default function TaskViewer(props: ViewProps) { setOpenDrawer(false)} + closeCallback={() => { + setOpenDrawer(false) + loadTasks() + }} completeTask={markComplete} deleteTask={deleteTask} - editTask={setTaskToEdit} + keys={[]} + types={[]} /> } diff --git a/src/tasks/taskActions.tsx b/src/tasks/taskActions.tsx new file mode 100644 index 0000000..d55590b --- /dev/null +++ b/src/tasks/taskActions.tsx @@ -0,0 +1,236 @@ +import { + IconButton, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Theme +} from "@mui/material"; +import { blue } from "@mui/material/colors"; +import MoreIcon from "@mui/icons-material/MoreVert"; +import ObjectTeamsIcon from "@mui/icons-material/SupervisedUserCircle"; +import { pond } from "protobuf-ts/pond"; +import React, { useState } from "react"; +import { isOffline } from "utils/environment"; +import ObjectTeams from "teams/ObjectTeams"; +import { Task, taskScope } from "models"; +import { makeStyles } from "@mui/styles"; +import { Done, Edit } from "@mui/icons-material"; +import DeleteIcon from "@mui/icons-material/Delete"; +import TaskSettings from "./TaskSettings"; + +const useStyles = makeStyles((theme: Theme) => ({ + shareIcon: { + color: blue["500"], + "&:hover": { + color: blue["600"] + } + }, + removeIcon: { + color: "var(--status-alert)" + }, + red: { + color: "var(--status-alert)" + } +})); + +interface Props { + task: Task; + permissions: pond.Permission[]; + keys: string[] + types: string[] + refreshCallback: () => void; + markComplete: (task: Task) => void; + removeTask: (task: Task) => void; +} + +interface OpenState { + // share: boolean; + // users: boolean; + teams: boolean; + settings: boolean; + // removeSelf: boolean; + delete: boolean +} + +export default function TaskActions(props: Props) { + const classes = useStyles(); + const { task, permissions, refreshCallback, keys, types } = props; + const [anchorEl, setAnchorEl] = React.useState(null); + const [openState, setOpenState] = useState({ + // share: false, + // users: false, + teams: false, + settings: false, + // removeSelf: false + delete: false + }); + + const groupMenu = () => { + // const canShare = permissions.includes(pond.Permission.PERMISSION_SHARE); + const canEdit = permissions.includes(pond.Permission.PERMISSION_WRITE); + const canManageUsers = permissions.includes(pond.Permission.PERMISSION_USERS); + return ( + setAnchorEl(null)} + keepMounted + disableAutoFocusItem> + {/* {!isOffline() && canShare && ( + { + setOpenState({ ...openState, share: true }); + setAnchorEl(null); + }}> + + + + + + )} */} + {/* {!isOffline() && canManageUsers && ( + { + setOpenState({ ...openState, users: true }); + setAnchorEl(null); + }}> + + + + + + )} */} + {!isOffline() && canEdit && ( + { + props.markComplete(props.task); + setAnchorEl(null); + }}> + + + + + + )} + {!isOffline() && canEdit && ( + { + setOpenState({ ...openState, settings: true }); + setAnchorEl(null); + }}> + + + + + + )} + {!isOffline() && canManageUsers && ( + { + setOpenState({ ...openState, teams: true }); + setAnchorEl(null); + }}> + + + + + + )} + {permissions.includes(pond.Permission.PERMISSION_WRITE) && ( + { + props.removeTask(props.task); + setAnchorEl(null); + }}> + + + + + + )} + {/* { + setOpenState({ ...openState, removeSelf: true }); + setAnchorEl(null); + }}> + + + + + */} + + ); + }; + + const dialogs = () => { + const key = task.key; + // const label = task.title(); + return ( + + {/* setOpenState({ ...openState, share: false })} + /> */} + {/* setOpenState({ ...openState, users: false })} + refreshCallback={refreshCallback} + /> */} + {/* setOpenState({ ...openState, removeSelf: false })} + /> */} + setOpenState({ ...openState, teams: false })} + keys={keys} + types={types} + /> + { + setOpenState({ ...openState, settings: false}) + if(r){ + refreshCallback() + } + }} + /> + + ); + }; + + return ( + + ) => setAnchorEl(event.currentTarget)}> + + + {groupMenu()} + {dialogs()} + + ); +} \ No newline at end of file diff --git a/src/teams/ObjectTeams.tsx b/src/teams/ObjectTeams.tsx index d327291..595143f 100644 --- a/src/teams/ObjectTeams.tsx +++ b/src/teams/ObjectTeams.tsx @@ -100,6 +100,8 @@ interface Props { userCallback?: (users?: User[] | Team[] | undefined) => void; dialog?: string; cardMode?: boolean; + keys?: string[]; + types?: string[]; } export default function ObjectTeams(props: Props) { @@ -120,7 +122,9 @@ export default function ObjectTeams(props: Props) { refreshCallback, userCallback, dialog, - cardMode + cardMode, + keys, + types } = props; const prevPermissions = usePrevious(permissions); const prevIsDialogOpen = usePrevious(isDialogOpen); @@ -146,7 +150,7 @@ export default function ObjectTeams(props: Props) { const load = useCallback(() => { setIsLoading(true); teamAPI - .listObjectTeams(scope, as) + .listObjectTeams(scope, as, keys, types) .then((response: any) => { let rTeams: Team[] = []; or(response.data, { teams: [] }).teams.forEach((user: any) => { @@ -588,6 +592,8 @@ export default function ObjectTeams(props: Props) { permissions={permissions} isDialogOpen={isShareObjectDialogOpen} closeDialogCallback={closeShareObjectDialog} + keys={keys} + types={types} /> ); diff --git a/src/teams/ShareWithTeam.tsx b/src/teams/ShareWithTeam.tsx index 070a8c7..89c9a03 100644 --- a/src/teams/ShareWithTeam.tsx +++ b/src/teams/ShareWithTeam.tsx @@ -88,6 +88,8 @@ interface Props { permissions: pond.Permission[]; isDialogOpen: boolean; closeDialogCallback: Function; + keys?: string[], + types?: string[] } export default function ShareObject(props: Props) { @@ -96,7 +98,7 @@ export default function ShareObject(props: Props) { const classes = useStyles(); const permissionAPI = usePermissionAPI(); const { info, success } = useSnackbar(); - const { scope, label, permissions, isDialogOpen, closeDialogCallback } = props; + const { scope, label, permissions, isDialogOpen, closeDialogCallback, keys, types } = props; const [sharedPermissions, setSharedPermissions] = useState([ pond.Permission.PERMISSION_READ ]); @@ -109,7 +111,7 @@ export default function ShareObject(props: Props) { const [teamKey, setTeamKey] = useState(""); const share = () => { - permissionAPI.shareObjectByKey(scope, teamKey, sharedPermissions).then(resp => { + permissionAPI.shareObjectByKey(scope, teamKey, sharedPermissions, keys, types).then(resp => { let shareBins = true; if (resp && resp.data && resp.data.existing) { success(label + " was shared with team"); @@ -123,7 +125,7 @@ export default function ShareObject(props: Props) { resp.data.bins.forEach(bin => { if (bin.settings) { let newScope = binScope(bin.settings?.key); - permissionAPI.shareObjectByKey(newScope, teamKey, sharedPermissions).catch(_err => { + permissionAPI.shareObjectByKey(newScope, teamKey, sharedPermissions, keys, types).catch(_err => { successBins = false; }); } @@ -143,7 +145,7 @@ export default function ShareObject(props: Props) { resp.data.gates.forEach(gate => { if (gate) { let newScope = { key: gate.key, kind: "gate" } as Scope; - permissionAPI.shareObjectByKey(newScope, teamKey, sharedPermissions).catch(_err => { + permissionAPI.shareObjectByKey(newScope, teamKey, sharedPermissions, keys, types).catch(_err => { successGates = false; }); } From ce41a2dc05626a19b7e7ae21aa104dbca33da4ac Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 25 Mar 2026 15:45:59 -0600 Subject: [PATCH 016/146] 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 --- src/bin/BinConditioningInteraction.tsx | 10 +- src/bin/BinSettings.tsx | 18 +- src/bin/BinStorageConditions.tsx | 18 +- src/bin/BinVisualizerV2.tsx | 8 +- src/bin/conditioning/modeChangeDialog.tsx | 9 +- src/common/ResponsiveTable.tsx | 2 +- src/device/DevicePresetsFromPicker.tsx | 7 +- src/device/presets/devicePresetCard.tsx | 20 +- src/gate/GateDeltaTempGraph.tsx | 7 +- src/gate/GateList.tsx | 3 +- src/models/user.ts | 4 + src/objects/ObjectDescriber.tsx | 224 +++++++++--------- src/objects/ObjectTable.tsx | 15 +- src/objects/bulkEditForms/bulkBinSettings.tsx | 10 +- src/pages/Heater.tsx | 3 +- src/pbHelpers/MeasurementDescriber.ts | 8 +- 16 files changed, 188 insertions(+), 178 deletions(-) diff --git a/src/bin/BinConditioningInteraction.tsx b/src/bin/BinConditioningInteraction.tsx index bd6c3c7..0a7e694 100644 --- a/src/bin/BinConditioningInteraction.tsx +++ b/src/bin/BinConditioningInteraction.tsx @@ -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"; @@ -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 @@ -178,7 +178,7 @@ export default function BinConditioningInteraction(props: Props) { let marks: Mark[] = []; if (condition.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) { - if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { + if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { labelTail = "°F"; } else { labelTail = "°C"; @@ -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 { diff --git a/src/bin/BinSettings.tsx b/src/bin/BinSettings.tsx index 58669d6..bbfb4b5 100644 --- a/src/bin/BinSettings.tsx +++ b/src/bin/BinSettings.tsx @@ -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 { getDistanceUnit, or, getGrainUnit } from "utils"; import { makeStyles } from "@mui/styles"; import { useNavigate } from "react-router-dom"; import BinSelector from "./BinSelector"; @@ -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)); @@ -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") { @@ -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: ( - {getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT + {user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? "F" : "C"} @@ -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: ( - {getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT + {user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? "F" : "C"} @@ -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: ( - {getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT + {user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? "F" : "C"} diff --git a/src/bin/BinStorageConditions.tsx b/src/bin/BinStorageConditions.tsx index fa0e957..b0e7e8a 100644 --- a/src/bin/BinStorageConditions.tsx +++ b/src/bin/BinStorageConditions.tsx @@ -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([-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: ( - {getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT + {user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? "°F" : "°C"} @@ -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"; diff --git a/src/bin/BinVisualizerV2.tsx b/src/bin/BinVisualizerV2.tsx index 2da6f78..1426dd0 100644 --- a/src/bin/BinVisualizerV2.tsx +++ b/src/bin/BinVisualizerV2.tsx @@ -40,7 +40,7 @@ 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 { getGrainUnit, or } from "utils"; import { useBinAPI } from "providers/pond/binAPI"; import BindaptIcon from "products/Bindapt/BindaptIcon"; import { @@ -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"; @@ -1676,7 +1676,7 @@ export default function BinVisualizer(props: Props) { fontWeight: 650, color: tempColour }}> - {ambient?.getTempString(getTemperatureUnit())} + {ambient?.getTempString(user.tempUnit())} diff --git a/src/bin/conditioning/modeChangeDialog.tsx b/src/bin/conditioning/modeChangeDialog.tsx index b31c8f8..cc17448 100644 --- a/src/bin/conditioning/modeChangeDialog.tsx +++ b/src/bin/conditioning/modeChangeDialog.tsx @@ -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([]) const interactionAPI = useInteractionsAPI() const componentAPI = useComponentAPI(); @@ -296,7 +295,7 @@ if (!selectedDevice) return; 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( @@ -392,7 +391,7 @@ if (!selectedDevice) return; 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( @@ -631,7 +630,7 @@ if (!selectedDevice) return; InputProps={{ endAdornment: ( - {getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT + {user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? "°F" : "℃"} diff --git a/src/common/ResponsiveTable.tsx b/src/common/ResponsiveTable.tsx index 4c21901..e3afd3f 100644 --- a/src/common/ResponsiveTable.tsx +++ b/src/common/ResponsiveTable.tsx @@ -614,7 +614,7 @@ export default function ResponsiveTable(props: Props) { {customElement && - + {customElement} diff --git a/src/device/DevicePresetsFromPicker.tsx b/src/device/DevicePresetsFromPicker.tsx index 900df87..5faaa1b 100644 --- a/src/device/DevicePresetsFromPicker.tsx +++ b/src/device/DevicePresetsFromPicker.tsx @@ -33,7 +33,6 @@ import { describeMeasurement } from "pbHelpers/MeasurementDescriber"; import { pond, quack } from "protobuf-ts/pond"; import React, { useEffect, useState } from "react"; import CableTopNodeSummary from "bin/CableTopNodeSummary"; -import { getTemperatureUnit } from "utils"; import { DevicePreset } from "models/DevicePreset"; import { Ambient } from "models/Ambient"; import { useGlobalState } from "providers"; @@ -182,7 +181,7 @@ export default function DevicePresets(props: DevicePresetsProps) { const [heaters, setHeaters] = useState([]); const [fans, setFans] = useState([]); //const [subscribeBinToAutoTopNode, setSubscribeBinToAutoTopNode] = useState(false); - const [{as}] = useGlobalState(); + const [{as, user}] = useGlobalState(); useEffect(() => { if (deviceComponents.get(deviceIndex.toString())) return; @@ -445,7 +444,7 @@ export default function DevicePresets(props: DevicePresetsProps) { 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( @@ -541,7 +540,7 @@ export default function DevicePresets(props: DevicePresetsProps) { } //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( diff --git a/src/device/presets/devicePresetCard.tsx b/src/device/presets/devicePresetCard.tsx index 2839df3..30cbc63 100644 --- a/src/device/presets/devicePresetCard.tsx +++ b/src/device/presets/devicePresetCard.tsx @@ -18,10 +18,9 @@ import { cloneDeep } from "lodash"; import { DevicePreset } from "models/DevicePreset"; import { ObjectTypeString } from "objects/ObjectDescriber"; import { pond } from "protobuf-ts/pond"; -import { useSnackbar } from "providers"; +import { useGlobalState, useSnackbar } from "providers"; import { useDevicePresetAPI } from "providers/pond/devicePresetAPI"; import React, { useEffect, useState } from "react"; -import { getTemperatureUnit } from "utils"; interface Props { preset: DevicePreset; @@ -54,6 +53,7 @@ export default function DevicePresetCard(props: Props) { const [humString, setHumString] = useState("0"); const [hum, setHum] = useState(0); const isMobile = useMobile(); + const [{user}] = useGlobalState(); useEffect(() => { setPresetName(preset.name); @@ -63,11 +63,11 @@ export default function DevicePresetCard(props: Props) { setHum(preset.settings.humidity); setHumString(preset.settings.humidity.toString()); let displayTemp = preset.settings.temperature.toString(); - if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { + if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { displayTemp = ((preset.settings.temperature * 9) / 5 + 32).toFixed(); } setTempString(displayTemp); - }, [preset]); + }, [preset, user]); const saveNewPreset = () => { let typestring; @@ -296,7 +296,7 @@ export default function DevicePresetCard(props: Props) { InputProps={{ endAdornment: ( - {getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT + {user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? "°F" : "°C"} @@ -307,7 +307,7 @@ export default function DevicePresetCard(props: Props) { onChange={e => { if (!isNaN(+e.target.value)) { let temp = +e.target.value; - if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { + if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { temp = Math.round((((temp - 32) * 5) / 9) * 10) / 10; } setTempC(temp); @@ -324,7 +324,7 @@ export default function DevicePresetCard(props: Props) { max={40} valueLabelDisplay="auto" valueLabelFormat={value => { - if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { + if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { return ((value * 9) / 5 + 32).toFixed() + "°F"; } return value.toFixed() + "°C"; @@ -350,7 +350,7 @@ export default function DevicePresetCard(props: Props) { max={40} valueLabelDisplay="auto" valueLabelFormat={value => { - if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { + if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { return ((value * 9) / 5 + 32).toFixed() + "°F"; } return value.toFixed() + "°C"; @@ -371,7 +371,7 @@ export default function DevicePresetCard(props: Props) { InputProps={{ endAdornment: ( - {getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT + {user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? "°F" : "°C"} @@ -382,7 +382,7 @@ export default function DevicePresetCard(props: Props) { onChange={e => { if (!isNaN(+e.target.value)) { let temp = +e.target.value; - if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { + if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { temp = Math.round((((temp - 32) * 5) / 9) * 10) / 10; } setTempC(temp); diff --git a/src/gate/GateDeltaTempGraph.tsx b/src/gate/GateDeltaTempGraph.tsx index d547b1c..cad0564 100644 --- a/src/gate/GateDeltaTempGraph.tsx +++ b/src/gate/GateDeltaTempGraph.tsx @@ -11,7 +11,6 @@ import { pond } from "protobuf-ts/pond" import { quack } from "protobuf-ts/quack" import { useGlobalState } from "providers" import { useEffect, useState } from "react" -import { getTemperatureUnit } from "utils" interface Props { @@ -190,7 +189,7 @@ export default function GateDeltaTempGraph(props: Props){ {"Delta Temp: "} 0 ? warmingColour : coolingColour, fontWeight: 500 }}> - {recent.value.toFixed(2) + (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? " °F" : " °C")} + {recent.value.toFixed(2) + (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? " °F" : " °C")}
@@ -213,8 +212,8 @@ export default function GateDeltaTempGraph(props: Props){ diff --git a/src/gate/GateList.tsx b/src/gate/GateList.tsx index 5910808..ba521cc 100644 --- a/src/gate/GateList.tsx +++ b/src/gate/GateList.tsx @@ -17,7 +17,6 @@ import moment from "moment"; import { pond } from "protobuf-ts/pond"; import { UnitMeasurement } from "models/UnitMeasurement"; import { quack } from "protobuf-ts/quack"; -import { getTemperatureUnit } from "utils"; import { teal } from "@mui/material/colors"; import { react } from "@babel/types"; import { getDeviceStateHelper } from "pbHelpers/DeviceState"; @@ -229,7 +228,7 @@ export default function GateList(props: Props) { }) if(ambientTemp && outletTemp){ let deltaTemp = outletTemp - ambientTemp - display = deltaTemp.toFixed(2) + (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? "°F" : "°C") + display = deltaTemp.toFixed(2) + (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? "°F" : "°C") }else{ display = "Sensor Missing" } diff --git a/src/models/user.ts b/src/models/user.ts index 6c46a31..a56ec5e 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -101,4 +101,8 @@ export class User { } return this.settings.features.includes(flag); } + + public tempUnit(): pond.TemperatureUnit { + return this.settings.temperatureUnit + } } diff --git a/src/objects/ObjectDescriber.tsx b/src/objects/ObjectDescriber.tsx index 643482a..3faad15 100644 --- a/src/objects/ObjectDescriber.tsx +++ b/src/objects/ObjectDescriber.tsx @@ -2,12 +2,12 @@ import { Column } from "common/ResponsiveTable"; import { Option } from "common/SearchSelect"; import GrainDescriber from "grain/GrainDescriber"; //import { Column } from "material-table"; -import { Bin, BinYard, Field } from "models"; +import { Bin, BinYard, Field, User } from "models"; //import { Gate } from "models/Gate"; import { GrainBag } from "models/GrainBag"; //import { ObjectHeater } from "models/ObjectHeater"; import { pond } from "protobuf-ts/pond"; -import { getDistanceUnit, getTemperatureUnit } from "utils"; +import { getDistanceUnit } from "utils"; interface Sort { order: string; //what to send to the backend list to sort by @@ -20,6 +20,8 @@ export interface ObjectExtension { isTransactionObject: boolean; //this will define the columns to be used for the object in a material table tableColumns: Column[]; + // Optional factory so columns can depend on the active user settings (units, formatting, etc.) + getTableColumns?: (user?: User) => Column[]; //this map will match the title of the column to what the backend needs to perform the ordering tableSort: Map; } @@ -32,6 +34,113 @@ const defaultObject: ObjectExtension = { tableSort: new Map() }; +const binColumns = (user?: User): Column[] => { + const temperatureUnit = + user?.tempUnit() ?? pond.TemperatureUnit.TEMPERATURE_UNIT_CELSIUS; + + return [ + { + title: "Name", + cellStyle: {padding: "16px"}, + render: row => { + return (
{row.settings.name}
); + } + }, + { + title: "Grain", + cellStyle: {padding: "16px"}, + render: row => { + let grain = row.settings.inventory?.grainType; + if (grain) { + return GrainDescriber(grain).name; + } + } + }, + { + title: "Bin Height", + cellStyle: {padding: "16px"}, + render: row => { + let d = row.settings.specs?.heightCm; + if (d) { + if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) { + return (d / 100).toFixed(2) + " m"; + } else { + return (d / 30.48).toFixed(2) + " ft"; + } + } + } + }, + { + title: "Bin Diameter", + cellStyle: {padding: "16px"}, + render: row => { + let d = row.settings.specs?.diameterCm; + if (d) { + if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) { + return (d / 100).toFixed(2) + " m"; + } else { + return (d / 30.48).toFixed(2) + " ft"; + } + } + } + }, + { + title: "Custom Grain Name", + cellStyle: {padding: "16px"}, + render: row => { + return row.settings.inventory?.customTypeName; + } + }, + { + title: "Grain Variant", + cellStyle: {padding: "16px"}, + render: row => { + return row.settings.inventory?.grainSubtype; + } + }, + { + title: "Grain Bushels", + cellStyle: {padding: "16px"}, + render: row => { + return (
{row.settings.inventory ? isNaN(row.settings.inventory.grainBushels) ? 0 : row.settings.inventory.grainBushels : 0}
); + } + }, + { + title: "Grain Capacity", + cellStyle: {padding: "16px"}, + render: row => { + return row.settings.specs?.bushelCapacity; + } + }, + { + title: "High Temp Warning", + cellStyle: {padding: "16px"}, + render: row => { + let t = row.settings.highTemp; + if (t) { + if (temperatureUnit === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { + return (t * 1.8 + 32).toFixed(2) + " °F"; + } + return t.toFixed(2) + " °C"; + } + } + }, + { + title: "Low Temp Warning", + cellStyle: {padding: "16px"}, + render: row => { + let t = row.settings.lowTemp; + if (t) { + if (temperatureUnit === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { + return (t * 1.8 + 32).toFixed(2) + " °F"; + } + return t.toFixed(2) + " °C"; + } + } + } + ] as Column[]; +}; + export const ObjectExtensions: Map = new Map([ [pond.ObjectType.OBJECT_TYPE_UNKNOWN, defaultObject], [ @@ -50,109 +159,8 @@ export const ObjectExtensions: Map = new Map([ name: "Bin", inventoryGroup: "grain", isTransactionObject: true, - tableColumns: [ - { - title: "Name", - cellStyle: {padding: "16px"}, - render: row => { - return (
{row.settings.name}
); - } - }, - { - title: "Grain", - cellStyle: {padding: "16px"}, - render: row => { - let grain = row.settings.inventory?.grainType; - if (grain) { - return GrainDescriber(grain).name; - } - } - }, - { - title: "Bin Height", - cellStyle: {padding: "16px"}, - render: row => { - let d = row.settings.specs?.heightCm; - if (d) { - if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) { - return (d / 100).toFixed(2) + " m"; - } else { - return (d / 30.48).toFixed(2) + " ft"; - } - } - } - }, - { - title: "Bin Diameter", - cellStyle: {padding: "16px"}, - render: row => { - let d = row.settings.specs?.diameterCm; - if (d) { - if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) { - return (d / 100).toFixed(2) + " m"; - } else { - return (d / 30.48).toFixed(2) + " ft"; - } - } - } - }, - { - title: "Custom Grain Name", - cellStyle: {padding: "16px"}, - render: row => { - return row.settings.inventory?.customTypeName; - } - }, - { - title: "Grain Variant", - cellStyle: {padding: "16px"}, - render: row => { - return row.settings.inventory?.grainSubtype; - } - }, - { - title: "Grain Bushels", - cellStyle: {padding: "16px"}, - render: row => { - return (
{row.settings.inventory ? isNaN(row.settings.inventory.grainBushels) ? 0 : row.settings.inventory.grainBushels : 0}
); - } - }, - { - title: "Grain Capacity", - cellStyle: {padding: "16px"}, - render: row => { - return row.settings.specs?.bushelCapacity; - } - }, - { - title: "High Temp Warning", - cellStyle: {padding: "16px"}, - render: row => { - let t = row.settings.highTemp; - if (t) { - if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { - return (t * 1.8 + 32).toFixed(2) + " °F"; - } else { - return t.toFixed(2) + " °C"; - } - } - } - }, - { - title: "Low Temp Warning", - cellStyle: {padding: "16px"}, - render: row => { - let t = row.settings.lowTemp; - if (t) { - if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { - return (t * 1.8 + 32).toFixed(2) + " °F"; - } else { - return t.toFixed(2) + " °C"; - } - } - } - } - ] as Column[], + tableColumns: binColumns(), + getTableColumns: (user?: User) => binColumns(user), tableSort: new Map([ ["Name", { order: "name", numerical: false }], ["Grain", { order: "inventory.grainType", numerical: false }], @@ -708,8 +716,7 @@ export const ObjectExtensions: Map = new Map([ ]); export default function ObjectDescriber(type: pond.ObjectType): ObjectExtension { - let describer = ObjectExtensions.get(type); - return describer ? describer : defaultObject; + return ObjectExtensions.get(type) ?? defaultObject; } /** @@ -745,7 +752,8 @@ export function SearchableObjects(): Option[] { Object.values(pond.ObjectType).forEach(obj => { if (typeof obj !== "string") { let ext = ObjectDescriber(obj); - if (ext.tableColumns.length > 0) { + const columns = ext.getTableColumns ? ext.getTableColumns() : ext.tableColumns; + if (columns.length > 0) { options.push({ label: ext.name, value: obj diff --git a/src/objects/ObjectTable.tsx b/src/objects/ObjectTable.tsx index 7a7b677..2ef6c86 100644 --- a/src/objects/ObjectTable.tsx +++ b/src/objects/ObjectTable.tsx @@ -1,6 +1,5 @@ import { Box, Button, Grid2 as Grid } from "@mui/material"; // import { getTableIcons } from "common/ResponsiveTable"; -import SearchBar from "common/SearchBar"; import SearchSelect, { Option } from "common/SearchSelect"; // import MaterialTable, { MTableToolbar } from "material-table"; // import { Bin, BinYard, Field } from "models"; @@ -22,9 +21,8 @@ import { import { useCallback, useEffect, useState } from "react"; import TeamSearch from "teams/TeamSearch"; import BulkBinSettings from "./bulkEditForms/bulkBinSettings"; -import BulkGrainBagSettings from "./bulkEditForms/bulkGrainBagSettings"; import ResponsiveTable from "common/ResponsiveTable"; -import { cloneDeep, indexOf } from "lodash"; +import { cloneDeep } from "lodash"; //import BulkGateSettings from "./bulkEditForms/bulkGateSettings"; interface customButton { @@ -61,6 +59,9 @@ export default function ObjectTable(props: Props) { const [selectedUser, setSelectedUser] = useState(as ?? user.id()); const [selectedPageRows, setSelectedPageRows] = useState>(new Map())//key is the page value is an array of row indexes for that page const [tableLoading, setTableLoading] = useState(false); + + const objectExtension = ObjectDescriber(currentType); + const columns = objectExtension.getTableColumns ? objectExtension.getTableColumns(user) : objectExtension.tableColumns; //the api's to load all the objects available to look at in this table const binAPI = useBinAPI(); const binyardAPI = useBinYardAPI(); @@ -366,7 +367,7 @@ export default function ObjectTable(props: Props) { { if (by !== -1) { - let colName = ObjectDescriber(currentType).tableColumns[by].title?.toString(); + let colName = ObjectDescriber(currentType, user).tableColumns[by].title?.toString(); let order; if (colName) { - order = ObjectDescriber(currentType).tableSort.get(colName); + order = ObjectDescriber(currentType, user).tableSort.get(colName); setCurrentOrder(order?.order); setCurrentDirection(direction); setIsNumerical(order?.numerical); diff --git a/src/objects/bulkEditForms/bulkBinSettings.tsx b/src/objects/bulkEditForms/bulkBinSettings.tsx index f3e01a9..ea8da7a 100644 --- a/src/objects/bulkEditForms/bulkBinSettings.tsx +++ b/src/objects/bulkEditForms/bulkBinSettings.tsx @@ -4,7 +4,7 @@ import { GrainOptions } from "grain"; import { pond } from "protobuf-ts/pond"; import { useBinAPI, useGlobalState, useSnackbar } from "providers"; import React, { useState } from "react"; -import { fahrenheitToCelsius, getDistanceUnit, getTemperatureUnit } from "utils"; +import { fahrenheitToCelsius, getDistanceUnit } from "utils"; interface Props { selectedBins: pond.Bin[]; @@ -19,7 +19,7 @@ export default function BulkBinSettings(props: Props) { const gridItemWidth = 3; // bin settings variables const [name, setName] = useState(); - const [{as}] = useGlobalState(); + const [{as, user}] = useGlobalState(); const [grainType, setGrainType] = useState(); const [grainOption, setGrainOption] = useState
diff --git a/src/grain/CustomGrainForm.tsx b/src/grain/CustomGrainForm.tsx index a13478c..8d5fd42 100644 --- a/src/grain/CustomGrainForm.tsx +++ b/src/grain/CustomGrainForm.tsx @@ -2,8 +2,7 @@ import {MenuItem, TextField } from "@mui/material" import { cloneDeep } from "lodash" import { pond } from "protobuf-ts/pond" import React, { useEffect, useRef, useState } from "react" -import { getGrainUnit } from "utils" - +import { useGlobalState } from "providers"; interface Props { grainSettings?: pond.GrainSettings onGrainSettingsChange: (settings: pond.GrainSettings, formValid: boolean) => void @@ -11,6 +10,7 @@ interface Props { export default function CustomGrainForm(props: Props) { const {grainSettings, onGrainSettingsChange} = props + const [{ user }] = useGlobalState(); const [newGrainSettings, setNewGrainSettings] = useState(pond.GrainSettings.create()) const [name, setName] = useState("") const [group, setGroup] = useState("") @@ -204,7 +204,7 @@ export default function CustomGrainForm(props: Props) { valid.current = validate(name, group, constantA, constantB, constantC, kgPerBushel, val) let settings = cloneDeep(newGrainSettings) if(!isNaN(parseFloat(val))){ - if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON){ + if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON){ settings.bushelsPerTonne = parseFloat(val)/0.907 }else{ settings.bushelsPerTonne = parseFloat(val) @@ -212,7 +212,7 @@ export default function CustomGrainForm(props: Props) { } settingsChanged(settings) }} - label={"Bushels per " + (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE ? " Tonne" : "Ton")}/> + label={"Bushels per " + (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE ? " Tonne" : "Ton")}/> ) } diff --git a/src/grain/GrainTransaction.tsx b/src/grain/GrainTransaction.tsx index 5605b3a..c6b2cfe 100644 --- a/src/grain/GrainTransaction.tsx +++ b/src/grain/GrainTransaction.tsx @@ -29,7 +29,6 @@ import { useTransactionAPI } from "providers"; import { useState, useEffect } from "react"; -import { getGrainUnit } from "utils"; interface Props { mainObject: Bin | GrainBag | Field | Contract; @@ -451,7 +450,7 @@ export default function GrainTransaction(props: Props) { }; const grainUnitDisplay = () => { - switch (getGrainUnit()){ + switch (user.grainUnit()){ case pond.GrainUnit.GRAIN_UNIT_TONNE: return "mT" case pond.GrainUnit.GRAIN_UNIT_TON: @@ -525,7 +524,7 @@ export default function GrainTransaction(props: Props) { onChange={e => { //if the user is viewing the grain in weight let grainVal = e.target.value; - if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE) { + if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE) { //need to convert what the user input (weight) into what is actually stored (bushels) //use source of the conversion value if it was selected, otherwise use the destination if (!isNaN(parseFloat(e.target.value))) { @@ -535,7 +534,7 @@ export default function GrainTransaction(props: Props) { grainVal = (+grainVal * selectedDestination.value.bushelsPerTonne()).toFixed(2); } } - }else if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON) { + }else if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON) { if (!isNaN(parseFloat(e.target.value))) { if (selectedSource) { if(selectedSource.value.bushelsPerTonne){ diff --git a/src/grainBag/grainBagInventoryGraph.tsx b/src/grainBag/grainBagInventoryGraph.tsx index 6a661a2..969ab3c 100644 --- a/src/grainBag/grainBagInventoryGraph.tsx +++ b/src/grainBag/grainBagInventoryGraph.tsx @@ -6,7 +6,6 @@ import moment, { Moment } from "moment"; import { pond } from "protobuf-ts/pond"; import { useGlobalState, useGrainBagAPI, useSnackbar } from "providers"; import { useEffect, useState } from "react"; -import { getGrainUnit } from "utils"; interface Props { grainBag: GrainBag; @@ -21,7 +20,7 @@ export default function GrainBagInventoryGraph(props: Props) { const [data, setData] = useState([]); const [loadingData, setLoadingData] = useState(false); const { openSnack } = useSnackbar(); - const [{as}] = useGlobalState(); + const [{as, user}] = useGlobalState(); useEffect(() => { if (grainBag.key() && !loadingData) { @@ -36,12 +35,12 @@ export default function GrainBagInventoryGraph(props: Props) { //let time = hist.timestamp; let val = hist.object.grainBagSettings.currentBushels ?? 0; if ( - getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && + user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && grainBag.bushelsPerTonne() > 1 ) { val = Math.round((val / grainBag.bushelsPerTonne()) * 100) / 100; } - if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && grainBag.bushelsPerTonne() > 1){ + if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && grainBag.bushelsPerTonne() > 1){ val = Math.round((val / (grainBag.bushelsPerTonne() * 0.907)) * 100) / 100; } if (val !== lastBushels) { @@ -55,10 +54,10 @@ export default function GrainBagInventoryGraph(props: Props) { }); if (barData.length === 0) { let val = grainBag.bushels() - if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && grainBag.bushelsPerTonne() > 1){ + if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && grainBag.bushelsPerTonne() > 1){ val = grainBag.bushels() / grainBag.bushelsPerTonne() } - if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && grainBag.bushelsPerTonne() > 1){ + if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && grainBag.bushelsPerTonne() > 1){ val = grainBag.bushels() / (grainBag.bushelsPerTonne() * 0.907) } barData.push({ @@ -79,10 +78,10 @@ export default function GrainBagInventoryGraph(props: Props) { const maxYAxis = () => { let val = grainBag.capacity() - if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE){ + if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE){ val = Math.round((val / grainBag.bushelsPerTonne()) * 100) / 100; } - if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && grainBag.bushelsPerTonne() > 1){ + if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && grainBag.bushelsPerTonne() > 1){ val = Math.round((val / (grainBag.bushelsPerTonne() * 0.907)) * 100) / 100; } return val diff --git a/src/grainBag/grainBagSettings.tsx b/src/grainBag/grainBagSettings.tsx index cf5f703..1ec5f2d 100644 --- a/src/grainBag/grainBagSettings.tsx +++ b/src/grainBag/grainBagSettings.tsx @@ -22,7 +22,6 @@ import { pond } from "protobuf-ts/pond"; import { useGlobalState, useGrainBagAPI, useSnackbar } from "providers"; import React, { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { getGrainUnit } from "utils"; const useStyles = makeStyles((theme: Theme) => { return ({ @@ -84,9 +83,9 @@ export default function GrainBagSettings(props: Props) { : grainBag.settings.length ); let grainVal = grainBag.bushels(); - if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE) { + if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE) { grainVal = grainBag.bushels() / grainBag.bushelsPerTonne(); - }else if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON) { + }else if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON) { grainVal = grainBag.bushels() / (grainBag.bushelsPerTonne() * 0.907) } setGrainFill(grainVal.toFixed(2)); @@ -120,7 +119,7 @@ export default function GrainBagSettings(props: Props) { const submit = () => { //if a bag was passed in do an update let tonneConversion = bushelConversion - if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON){ + if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON){ tonneConversion = bushelConversion / 0.907 } if (grainBag) { @@ -255,9 +254,9 @@ export default function GrainBagSettings(props: Props) { const grainUnitDisplay = () => { if(bushelConversion > 1){ - if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE){ + if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE){ return "mT" - }else if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON){ + }else if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON){ return "t" } }else{ @@ -422,7 +421,7 @@ export default function GrainBagSettings(props: Props) { value={grainFill} onChange={e => { let bushelVal = +e.target.value; - if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE || getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON) { + if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE || user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON) { //convert the number as a weight into the bushel value, no changing or conversions necessary // since these are both user entered fields and should be the same unit (ton or tonne) bushelVal = +e.target.value * (bushelConversion > 0 ? bushelConversion : 1); diff --git a/src/grainBag/grainBagVisualizer.tsx b/src/grainBag/grainBagVisualizer.tsx index 1d0b5d8..319ad39 100644 --- a/src/grainBag/grainBagVisualizer.tsx +++ b/src/grainBag/grainBagVisualizer.tsx @@ -19,9 +19,9 @@ import moment from "moment"; import { pond } from "protobuf-ts/pond"; import { useEffect, useState } from "react"; import { getThemeType } from "theme"; -import { getGrainUnit } from "utils"; import GrainBagSVG from "./grainBagSVG"; import { makeStyles } from "@mui/styles"; +import { useGlobalState } from "providers"; interface Props { grainBag: GrainBag; @@ -68,6 +68,7 @@ export default function GrainBagVisualizer(props: Props) { const [grainDiff, setGrainDiff] = useState(); const theme = useTheme(); const [openTransaction, setOpenTransaction] = useState(false); + const [{ user }] = useGlobalState(); useEffect(() => { setFillLevel((grainBag.bushels() / grainBag.capacity()) * 100); @@ -83,13 +84,13 @@ export default function GrainBagVisualizer(props: Props) { const grainOverlay = () => { let displayPending = pendingGrainAmount; let displayDiff = grainDiff; - if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && grainBag.bushelsPerTonne() > 1) { + if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && grainBag.bushelsPerTonne() > 1) { if (displayPending && displayDiff) { displayPending = Math.round((displayPending / grainBag.bushelsPerTonne()) * 100) / 100; displayDiff = Math.round((displayDiff / grainBag.bushelsPerTonne()) * 100) / 100; } } - if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && grainBag.bushelsPerTonne() > 1) { + if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && grainBag.bushelsPerTonne() > 1) { if (displayPending && displayDiff) { displayPending = Math.round((displayPending / (grainBag.bushelsPerTonne() * 0.907)) * 100) / 100; displayDiff = Math.round((displayDiff / (grainBag.bushelsPerTonne()* 0.907)) * 100) / 100; @@ -148,7 +149,7 @@ export default function GrainBagVisualizer(props: Props) { }; const grainAmountDisplay = () => { - if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && grainBag.bushelsPerTonne() > 1) { + if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && grainBag.bushelsPerTonne() > 1) { return (
@@ -157,7 +158,7 @@ export default function GrainBagVisualizer(props: Props) { ({grainBag.fillPercent()}%)
); - } else if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && grainBag.bushelsPerTonne() > 1) { + } else if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && grainBag.bushelsPerTonne() > 1) { return (
diff --git a/src/models/Bin.ts b/src/models/Bin.ts index e720fec..5ad8635 100644 --- a/src/models/Bin.ts +++ b/src/models/Bin.ts @@ -1,8 +1,8 @@ import GrainDescriber from "grain/GrainDescriber"; import { cloneDeep } from "lodash"; import { MarkerData } from "maps/mapMarkers/Markers"; +import { User } from "models"; import { pond } from "protobuf-ts/pond"; -import { getGrainUnit } from "utils"; import { stringToMaterialColour } from "utils/strings"; import { or } from "utils/types"; @@ -236,11 +236,11 @@ export class Bin { return bpt; } - public grainInventory(): number { + public grainInventory(user: User): number { let grain = this.bushels() - if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && this.bushelsPerTonne() > 1){ + if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && this.bushelsPerTonne() > 1){ grain = this.bushels() / this.bushelsPerTonne() - }else if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && this.bushelsPerTonne() > 1){ + }else if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && this.bushelsPerTonne() > 1){ grain = this.bushels() / (this.bushelsPerTonne() * 0.907) } return Math.round(grain*100)/100 diff --git a/src/models/Contract.ts b/src/models/Contract.ts index ce55093..81bc001 100644 --- a/src/models/Contract.ts +++ b/src/models/Contract.ts @@ -2,8 +2,9 @@ import { Option } from "common/SearchSelect"; import { GrainOptions, ToGrainOption } from "grain"; import GrainDescriber from "grain/GrainDescriber"; import { cloneDeep } from "lodash"; +import { User } from "models"; import { pond } from "protobuf-ts/pond"; -import { getGrainUnit, stringToMaterialColour } from "utils"; +import { stringToMaterialColour } from "utils"; import { or } from "utils/types"; export class Contract { @@ -14,26 +15,28 @@ export class Contract { public label: string = ""; private objKey: string = ""; - public static create(pb?: pond.Contract): Contract { + public static create(pb?: pond.Contract, user?: User): Contract { let my = new Contract(); if (pb) { my.settings = pond.ContractSettings.fromObject(cloneDeep(or(pb.settings, {}))); my.title = pb.name; my.objKey = pb.key; - my.unit = my.measurementUnit(); + my.unit = my.measurementUnit(user); my.colour = my.commodityColour(); my.label = my.commodityLabel(); } return my; } - private measurementUnit(): string { + private measurementUnit(user?: User): string { if (this.settings.type === pond.ContractType.CONTRACT_TYPE_GRAIN) { - if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && this.conversionValue() > 1){ - return "mT" - } - if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && this.conversionValue() > 1){ - return "t" + if( user ){ + if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && this.conversionValue() > 1){ + return "mT" + } + if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && this.conversionValue() > 1){ + return "t" + } } return "bu"; } @@ -63,21 +66,22 @@ export class Contract { return label; } - public static clone(other?: Contract): Contract { + public static clone(other?: Contract, user?: User): Contract { if (other) { return Contract.create( pond.Contract.fromObject({ title: other.title, key: other.objKey, settings: cloneDeep(other.settings) - }) + }), + user ); } - return Contract.create(); + return Contract.create(undefined, user); } - public static any(data: any): Contract { - return Contract.create(pond.Contract.fromObject(cloneDeep(data))); + public static any(data: any, user?: User): Contract { + return Contract.create(pond.Contract.fromObject(cloneDeep(data)), user); } public key(): string { @@ -204,28 +208,28 @@ export class Contract { return this.conversionValue(); } - public sizeInPreferredUnit(): number { + public sizeInPreferredUnit(user: User): number { let size = this.settings.size; switch (this.settings.type) { case pond.ContractType.CONTRACT_TYPE_GRAIN: - if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && this.conversionValue() > 1) { + if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && this.conversionValue() > 1) { size = size / this.conversionValue(); } - if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && this.conversionValue() > 1){ + if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && this.conversionValue() > 1){ size = size / (this.conversionValue() * 0.907); //the conversion for grain will be stored in metric tonnes, this will convert it to US tons } } return Math.round(size * 100) / 100; } - public deliveredInPreferredUnit(): number { + public deliveredInPreferredUnit(user: User): number { let del = this.settings.delivered; switch (this.settings.type) { case pond.ContractType.CONTRACT_TYPE_GRAIN: - if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && this.conversionValue() > 1) { + if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && this.conversionValue() > 1) { del = del / this.conversionValue(); } - if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && this.conversionValue() > 1){ + if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && this.conversionValue() > 1){ del = del / (this.conversionValue() * 0.907); //the conversion for grain will be stored in metric tonnes, this will convert it to US tons } } @@ -237,14 +241,15 @@ export class Contract { public static toStoredUnit( secondaryUnitVal: number, contractType: pond.ContractType, - conversionValue: number + conversionValue: number, + user: User ): number { let storedUnitVal = secondaryUnitVal; switch (contractType) { //use the value and conversion they entered directly, it is safe to assume they are both the same unit (ton vs tonne) so dont need to convert anything //before converting to bushels case pond.ContractType.CONTRACT_TYPE_GRAIN: - if ((getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE || getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON) && conversionValue > 1) { + if ((user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE || user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON) && conversionValue > 1) { storedUnitVal = secondaryUnitVal * conversionValue; } } diff --git a/src/pages/Bins.tsx b/src/pages/Bins.tsx index 1b9c75e..ad30d23 100644 --- a/src/pages/Bins.tsx +++ b/src/pages/Bins.tsx @@ -63,7 +63,7 @@ import { } from "providers"; import React, { SetStateAction, useCallback, useEffect, useState } from "react"; // import { getThemeType } from "theme"; -import { getGrainUnit, stringToMaterialColour } from "utils"; +import { stringToMaterialColour } from "utils"; //import InfiniteScroll from "react-infinite-scroller"; import PageContainer from "./PageContainer"; import ObjectTable from "objects/ObjectTable"; @@ -934,7 +934,7 @@ export default function Bins(props: Props) { return " bu"; } - switch (getGrainUnit()) { + switch (user.grainUnit()) { case pond.GrainUnit.GRAIN_UNIT_TONNE: return " mT"; case pond.GrainUnit.GRAIN_UNIT_TON: @@ -947,9 +947,9 @@ export default function Bins(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) } } @@ -959,9 +959,9 @@ export default function Bins(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 diff --git a/src/pages/Contracts.tsx b/src/pages/Contracts.tsx index e93850b..fee4834 100644 --- a/src/pages/Contracts.tsx +++ b/src/pages/Contracts.tsx @@ -11,7 +11,7 @@ import PageContainer from "./PageContainer"; export default function Contracts() { const [openSettings, setOpenSettings] = useState(false); - const [{ as }] = useGlobalState() + const [{ as, user }] = useGlobalState() const contractAPI = useContractAPI(); const [contracts, setContracts] = useState([]); const [loading, setLoading] = useState(false); @@ -42,7 +42,7 @@ export default function Contracts() { let contractPermissions: Map = new Map(); if (resp.data.contracts){ resp.data.contracts.forEach(contract => { - let c = Contract.create(contract); + let c = Contract.create(contract, user); contracts.push(c); let p = pond.EvaluatePermissionsResponse.fromObject( resp.data.contractPermissions[c.key()] @@ -56,7 +56,7 @@ export default function Contracts() { }) .catch(err => {}); } - }, [contractAPI, year, as]); // eslint-disable-line react-hooks/exhaustive-deps + }, [contractAPI, year, as, user]); // eslint-disable-line react-hooks/exhaustive-deps return ( diff --git a/src/transactions/transactionDataDisplay.tsx b/src/transactions/transactionDataDisplay.tsx index b64db64..3bd6220 100644 --- a/src/transactions/transactionDataDisplay.tsx +++ b/src/transactions/transactionDataDisplay.tsx @@ -2,8 +2,8 @@ import { Box, Typography } from "@mui/material"; import GrainDescriber from "grain/GrainDescriber"; import { Transaction } from "models/Transaction"; import { pond } from "protobuf-ts/pond"; +import { useGlobalState } from "providers"; import React from "react"; -import { getGrainUnit } from "utils"; interface Props { transaction: Transaction; @@ -11,13 +11,14 @@ interface Props { export default function TransactionDataDisplay(props: Props) { const { transaction } = props; + const [{ user }] = useGlobalState(); console.log(transaction) const grainDisplay = (gt: pond.GrainTransaction) => { - if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && gt.bushelsPerTonne > 1){ + if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && gt.bushelsPerTonne > 1){ return "Weight: " + Math.round(gt.bushels / gt.bushelsPerTonne*100)/100 + " mT" } - if(getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && gt.bushelsPerTonne > 1){ + if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && gt.bushelsPerTonne > 1){ return "Weight: " + Math.round(gt.bushels / (gt.bushelsPerTonne*0.907)*100)/100 + " t" } return "Bushels: " + gt.bushels diff --git a/src/utils/units.ts b/src/utils/units.ts index 3194668..075a9e1 100644 --- a/src/utils/units.ts +++ b/src/utils/units.ts @@ -57,6 +57,7 @@ export function setDistanceUnit(unit: pond.DistanceUnit) { localStorage.setItem("distance", unit === pond.DistanceUnit.DISTANCE_UNIT_METERS ? "m" : "ft"); } +// function is deprecated, use User.grainUnit() instead export function getGrainUnit(): pond.GrainUnit { switch(localStorage.getItem("grainUnit")){ case "mT": @@ -84,12 +85,10 @@ export function setGrainUnit(unit: pond.GrainUnit) { default: localStorage.setItem("grainUnit", "bu") } - // localStorage.setItem("grainUnit", unit === pond.GrainUnit.GRAIN_UNIT_WEIGHT ? "mT" : "bu"); } export const distanceConversion = (val: number, distanceUnit: pond.DistanceUnit) => { let converted = val; - if (distanceUnit === pond.DistanceUnit.DISTANCE_UNIT_METERS) { converted = val / 3.281; } From 8633c197abbc19bc68fe09c9294b72c8066f9e2f Mon Sep 17 00:00:00 2001 From: csawatzky Date: Fri, 27 Mar 2026 14:35:02 -0600 Subject: [PATCH 022/146] 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 --- src/providers/pond/permissionAPI.tsx | 4 +++- src/teams/ShareWithTeam.tsx | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/providers/pond/permissionAPI.tsx b/src/providers/pond/permissionAPI.tsx index ea8e0b9..3005599 100644 --- a/src/providers/pond/permissionAPI.tsx +++ b/src/providers/pond/permissionAPI.tsx @@ -25,6 +25,7 @@ export interface IPermissionAPIContext { shareObjectByKey: ( scope: Scope, key: string, + kind: string, permissions: pond.Permission[], keys?: string[], type?: string[] @@ -138,7 +139,7 @@ export default function PermissionProvider(props: PropsWithChildren) { }) }; - const shareObjectByKey = (scope: Scope, key: string, permissions: pond.Permission[], keys?: string[], types?: string[]) => { + const shareObjectByKey = (scope: Scope, key: string, kind: string, permissions: pond.Permission[], keys?: string[], types?: string[]) => { let url = pondURL("/" + scope.kind + "s/" + scope.key + "/shareByKey") + (keys ? "?keys=" + keys.join(",") : "") + (types ? "&types=" + types.join(",") : "") if (as) url = pondURL(`/${scope.kind}s/${scope.key}/shareByKey?as=${as}`) + (keys ? "&keys=" + keys.join(",") : "") + @@ -146,6 +147,7 @@ export default function PermissionProvider(props: PropsWithChildren) { return new Promise((resolve, reject) => { post(url, { key: key, + kind: kind, permissions: permissions.map(permission => permissionToString(permission)) }).then(resp => { return resolve(resp) diff --git a/src/teams/ShareWithTeam.tsx b/src/teams/ShareWithTeam.tsx index 89c9a03..ffda3f0 100644 --- a/src/teams/ShareWithTeam.tsx +++ b/src/teams/ShareWithTeam.tsx @@ -111,7 +111,8 @@ export default function ShareObject(props: Props) { const [teamKey, setTeamKey] = useState(""); const share = () => { - permissionAPI.shareObjectByKey(scope, teamKey, sharedPermissions, keys, types).then(resp => { + + permissionAPI.shareObjectByKey(scope, teamKey, "team", sharedPermissions, keys, types).then(resp => { let shareBins = true; if (resp && resp.data && resp.data.existing) { success(label + " was shared with team"); @@ -125,7 +126,7 @@ export default function ShareObject(props: Props) { resp.data.bins.forEach(bin => { if (bin.settings) { let newScope = binScope(bin.settings?.key); - permissionAPI.shareObjectByKey(newScope, teamKey, sharedPermissions, keys, types).catch(_err => { + permissionAPI.shareObjectByKey(newScope, teamKey, "team", sharedPermissions, keys, types).catch(_err => { successBins = false; }); } @@ -145,7 +146,7 @@ export default function ShareObject(props: Props) { resp.data.gates.forEach(gate => { if (gate) { let newScope = { key: gate.key, kind: "gate" } as Scope; - permissionAPI.shareObjectByKey(newScope, teamKey, sharedPermissions, keys, types).catch(_err => { + permissionAPI.shareObjectByKey(newScope, teamKey, "team", sharedPermissions, keys, types).catch(_err => { successGates = false; }); } From 186604dccfb1ebbde48cc08c508ace121f98f55e Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 31 Mar 2026 13:37:49 -0600 Subject: [PATCH 023/146] final adjustments to the field task list and the field drawer and field page using the tasks with the permissions in the correct way --- src/field/FieldTaskList.tsx | 43 +++++++++++------- src/maps/mapDrawers/FieldDrawer.tsx | 3 +- src/pages/Bin.tsx | 4 +- src/pages/Field.tsx | 11 ++--- src/providers/pond/permissionAPI.tsx | 29 ++++++++++++ src/tasks/TaskCard.tsx | 1 - src/tasks/TaskDrawer.tsx | 45 ++++--------------- src/tasks/TaskList.tsx | 67 ---------------------------- src/tasks/TaskViewer.tsx | 45 +------------------ src/tasks/taskActions.tsx | 1 + src/teams/ObjectTeams.tsx | 25 +++++++++-- 11 files changed, 97 insertions(+), 177 deletions(-) diff --git a/src/field/FieldTaskList.tsx b/src/field/FieldTaskList.tsx index 3b0d1e3..66a32c3 100644 --- a/src/field/FieldTaskList.tsx +++ b/src/field/FieldTaskList.tsx @@ -7,6 +7,8 @@ import TaskList from "tasks/TaskList" import TaskSettings from "tasks/TaskSettings" import AddIcon from "@mui/icons-material/Add"; import { makeStyles } from "@mui/styles"; +import { openSnackbar } from "providers/Snackbar" +import { cloneDeep } from "lodash" @@ -70,8 +72,10 @@ export default function FieldTaskList(props: Props) { const [openTaskDrawer, setOpenTaskDrawer] = useState(false) const [opentaskSettings, setOpenTaskSettings] = useState(false) const [taskMap, setTaskMap] = useState>(new Map()) + //load the tasks const loadTasks = useCallback(() => { + console.log("loading tasks") taskAPI.listTasks(50, 0, "asc", "start", undefined, undefined, undefined, undefined, undefined, [field], ["field"]).then(resp => { if(resp.data.tasks){ let newMap: Map = new Map() @@ -89,20 +93,24 @@ export default function FieldTaskList(props: Props) { loadTasks() },[loadTasks]) - const addNewTask = (task: Task) => { - console.log() + const deleteTask = (task: Task) => { + taskAPI.removeTask(task.key).then(resp => { + openSnackbar("success", "Task has been removed") + loadTasks() + }).catch(err => { + openSnackbar("error", "There was a problem removing the task") + }) } - const deleteTask = () => { - - } - - const markComplete = () => { - - } - - const updateTask = () => { - + const markComplete = (task: Task) => { + let settings = cloneDeep(task.settings) + settings.complete = !task.settings.complete + taskAPI.updateTask(task.key, settings).then(resp => { + openSnackbar("success", "Task has been updated") + loadTasks() + }).catch(err => { + openSnackbar("error", "There was a problem updating the task") + }) } //return the task list return ( @@ -113,9 +121,12 @@ export default function FieldTaskList(props: Props) { open={openTaskDrawer} deleteTask={deleteTask} completeTask={markComplete} - closeCallback={() => { + closeCallback={(refresh) => { setOpenTaskDrawer(false) setSelectedTask(undefined) + if (refresh) { + loadTasks() + } }} keys={[field]} types={["field"]} @@ -123,7 +134,6 @@ export default function FieldTaskList(props: Props) { } { let task = taskMap.get(id) @@ -141,8 +151,11 @@ export default function FieldTaskList(props: Props) { /> { + onClose={(reload) => { setOpenTaskSettings(false) + if(reload){ + loadTasks() + } }} keys={[field]} types={["field"]} diff --git a/src/maps/mapDrawers/FieldDrawer.tsx b/src/maps/mapDrawers/FieldDrawer.tsx index f6e864b..07adbf9 100644 --- a/src/maps/mapDrawers/FieldDrawer.tsx +++ b/src/maps/mapDrawers/FieldDrawer.tsx @@ -14,7 +14,6 @@ import DisplayDrawer from "common/DisplayDrawer"; import HarvestPlanDisplay from "harvestPlan/HarvestPlanDisplay"; import { Field, fieldScope, HarvestPlan, teamScope } from "models"; import React, { useEffect, useState } from "react"; -import TaskViewer from "tasks/TaskViewer"; import Weather from "weather/weather"; import { getThemeType } from "theme"; import GrainDescriber from "grain/GrainDescriber"; @@ -219,7 +218,7 @@ export default function FieldDrawer(props: Props) { TabIndicatorProps={{ style: { background: "rgba(255,255,0,255)" } }}> - + diff --git a/src/pages/Bin.tsx b/src/pages/Bin.tsx index 48bba18..4cbbba7 100644 --- a/src/pages/Bin.tsx +++ b/src/pages/Bin.tsx @@ -512,7 +512,7 @@ export default function Bin(props: Props) { if (showTasks()) { return ( - + ); } @@ -1156,7 +1156,7 @@ export default function Bin(props: Props) { setTaskDrawer(false)}> - + )} diff --git a/src/pages/Field.tsx b/src/pages/Field.tsx index 15c29db..94159fc 100644 --- a/src/pages/Field.tsx +++ b/src/pages/Field.tsx @@ -10,12 +10,12 @@ import { Settings } from "@mui/icons-material"; import { pond } from "protobuf-ts/pond"; import FieldMinimap from "field/Fieldminimap"; import Weather from "weather/weather"; -import TaskViewer from "tasks/TaskViewer"; import HarvestPlanDisplay from "harvestPlan/HarvestPlanDisplay"; import { cloneDeep } from "lodash"; import HarvestPlanTable from "harvestPlan/HarvestPlanTable"; import { makeStyles } from "@mui/styles"; import FieldSettings from "field/FieldSettings"; +import FieldTaskList from "field/FieldTaskList"; const useStyles = makeStyles((theme: Theme) => { return ({ @@ -185,12 +185,9 @@ export default function FieldPage() { } const tasks = () => { - let taskLoadKeys: string[] = []; - if (!planLoading) { - field.key() !== "" && taskLoadKeys.push(field.key()); - //hPlan.key() !== "" && taskLoadKeys.push(hPlan.key()); - } - return () + return ( + + ) } const desktopView = () => { diff --git a/src/providers/pond/permissionAPI.tsx b/src/providers/pond/permissionAPI.tsx index 3005599..68fa5c9 100644 --- a/src/providers/pond/permissionAPI.tsx +++ b/src/providers/pond/permissionAPI.tsx @@ -7,10 +7,16 @@ import { useGlobalState } from "providers"; import { createContext, PropsWithChildren, useContext } from "react"; import { objectQueryParams, pondURL } from "./pond"; +export interface PermissionChanges { + key: string; //the key of the object having its permissions changed (parent) + permissions: string[] //the new permissions the parent will have to the child +} + export interface IPermissionAPIContext { getPermissions: (user: string, keys: string[], types: string[]) => Promise>; removePermissions: (user: string, scope: Scope) => Promise>; updatePermissions: (scope: Scope, users: User[] | Team[]) => Promise>; + updateObjectPermissions: (key: string, type: string, parentType: string, changes: PermissionChanges[], keys: string[], types: string[]) => Promise>; updateRelativePermissions: ( parentScope: Scope, childScope: Scope, @@ -96,6 +102,28 @@ export default function PermissionProvider(props: PropsWithChildren) { }) }; + const updateObjectPermissions = (key: string, type: string, parentType: string, changes: PermissionChanges[], keys: string[], types: string[]) => { + let body = { + childKey: key, + childType: type, + parentType: parentType, + permissionChanges: changes + }; + + if (as) { + keys.unshift(as) + types.unshift("team") + } + + return new Promise((resolve, reject) => { + put(pondURL("/" + type + "s/" + key + "/objectPermissions?keys=" + keys + "&types=" + types ), body).then(resp => { + return resolve(resp) + }).catch(err => { + return reject(err) + }) + }) + } + const shareObject = ( scope: Scope, email: string, @@ -207,6 +235,7 @@ export default function PermissionProvider(props: PropsWithChildren) { getPermissions, removePermissions, updatePermissions, + updateObjectPermissions, updateRelativePermissions, shareObject, shareObjectByKey, diff --git a/src/tasks/TaskCard.tsx b/src/tasks/TaskCard.tsx index 281bbb2..2cae4d3 100644 --- a/src/tasks/TaskCard.tsx +++ b/src/tasks/TaskCard.tsx @@ -18,7 +18,6 @@ import TaskActions from "./taskActions"; interface Props { task: Task; reLoad: () => void; - editTaskMethod: (task: Task) => void; markComplete: (task: Task) => void; deleteTask: (task: Task) => void; openTaskPage: (taskId: string) => void; diff --git a/src/tasks/TaskDrawer.tsx b/src/tasks/TaskDrawer.tsx index 3c206fb..0eade2d 100644 --- a/src/tasks/TaskDrawer.tsx +++ b/src/tasks/TaskDrawer.tsx @@ -32,7 +32,7 @@ import { useGlobalState } from "providers"; interface Props { task: Task; open: boolean; - closeCallback: () => void; + closeCallback: (refresh: boolean) => void; deleteTask: (task: Task) => void; completeTask: (task: Task) => void; keys: string[] @@ -121,7 +121,7 @@ export default function TaskDrawer(props: Props) { const [worker, setWorker] = useState(); useEffect(() => { - permissionAPI.getPermissions(user.id(), [task.key], ["task"]).then(resp => { + permissionAPI.getPermissions(user.id(), props.keys, props.types).then(resp => { setPermissions(pond.EvaluatePermissionsResponse.fromObject(resp.data).permissions) }) if (task.start()) { @@ -134,39 +134,6 @@ export default function TaskDrawer(props: Props) { }); }, [user, task, userAPI, permissionAPI]); - // const taskActions = () => { - // return ( - // setMenuAnchorEl(null)} - // disableAutoFocusItem> - // { - // editTask(props.task); - // setMenuAnchorEl(null); - // }}> - // - // - // - // - // - // { - // deleteTask(props.task); - // setMenuAnchorEl(null); - // closeCallback(); - // }}> - // - // - // - // - // - // - // ); - // }; - const bodyButtons = () => { return ( @@ -195,7 +162,9 @@ export default function TaskDrawer(props: Props) { permissions={permissions} task={task} removeTask={deleteTask} - refreshCallback={closeCallback} + refreshCallback={() => { + closeCallback(true) + }} keys={props.keys} types={props.types} /> @@ -335,7 +304,9 @@ export default function TaskDrawer(props: Props) { displayNext={() => {}} displayPrev={() => {}} drawerBody={drawerBody()} - onClose={closeCallback} + onClose={() => { + closeCallback(false)} + } open={open} /> ); diff --git a/src/tasks/TaskList.tsx b/src/tasks/TaskList.tsx index 8f367ef..f94f838 100644 --- a/src/tasks/TaskList.tsx +++ b/src/tasks/TaskList.tsx @@ -12,7 +12,6 @@ import ButtonGroup from "common/ButtonGroup"; interface Props { tasks: Task[]; - editTaskMethod: (task: Task) => void; markComplete: (task: Task) => void; deleteTask: (task: Task) => void; openTask: (taskId: string) => void; @@ -26,7 +25,6 @@ interface Props { export default function TaskList(props: Props) { const { - editTaskMethod, markComplete, deleteTask, openTask, @@ -74,60 +72,10 @@ export default function TaskList(props: Props) { setComplete(complete); }, [tasks, dateToView]); - // const StyledToggleButtonGroup = withStyles(theme => ({ - // grouped: { - // margin: theme.spacing(-0.5), - // border: "none", - // padding: theme.spacing(1), - // "&:not(:first-child):not(:last-child)": { - // borderRadius: 24, - // marginRight: theme.spacing(0.5), - // marginLeft: theme.spacing(0.5) - // }, - // "&:first-child": { - // borderRadius: 24, - // marginLeft: theme.spacing(0.25) - // }, - // "&:last-child": { - // borderRadius: 24, - // marginRight: theme.spacing(0.25) - // } - // }, - // root: { - // backgroundColor: darken( - // theme.palette.background.paper, - // getThemeType() === "light" ? 0.05 : 0.25 - // ), - // borderRadius: 24, - // content: "border-box" - // } - // }))(ToggleButtonGroup); - - // const StyledToggle = withStyles({ - // root: { - // backgroundColor: "transparent", - // overflow: "visible", - // content: "content-box", - // "&$selected": { - // backgroundColor: "gold", - // color: "black", - // borderRadius: 24, - // fontWeight: "bold" - // }, - // "&$selected:hover": { - // backgroundColor: "rgb(255, 255, 0)", - // color: "black", - // borderRadius: 24 - // } - // }, - // selected: {} - // })(ToggleButton); - const incompleteTasks = incomplete.map((task, index) => ( markComplete(task)} deleteTask={(task: Task) => deleteTask(task)} reLoad={reLoad} @@ -142,7 +90,6 @@ export default function TaskList(props: Props) { markComplete(task)} deleteTask={(task: Task) => deleteTask(task)} reLoad={reLoad} @@ -170,20 +117,6 @@ export default function TaskList(props: Props) { } ]} /> - {/* - setViewing("upcoming")}> - Upcoming - - setViewing("complete")} - value={"complete"} - aria-label="complete"> - Complete - - */} {location !== "/tasks" && ( diff --git a/src/tasks/TaskViewer.tsx b/src/tasks/TaskViewer.tsx index 00fed3c..b283c5f 100644 --- a/src/tasks/TaskViewer.tsx +++ b/src/tasks/TaskViewer.tsx @@ -99,7 +99,6 @@ export default function TaskViewer(props: ViewProps) { overlayButton } = props; const [{ user, as }] = useGlobalState(); - //const [{ as }] = useGlobalState(); const taskAPI = useTaskAPI(); const { openSnack } = useSnackbar(); const [tasks, setTasks] = useState>(new Map([])); @@ -147,36 +146,6 @@ export default function TaskViewer(props: ViewProps) { } }; - const setTaskToEdit = (task: Task) => { - setEditTask(task); - openDialog(); - }; - - - // const loadMultitask = useCallback(() => { - // if (!loadKeys) return; - // if (loadKeys.length > 0) { - // let temp = new Map(); - // setLoaded(false); - // taskAPI - // .getMultiTasks(loadKeys, as) - // .then(resp => { - // if(resp.data.tasks){ - // resp.data.tasks.forEach(task => { - // if (task.settings) { - // temp.set(task.key, Task.any(task)); - // } - // }); - // } - // setTasks(temp); - // setLoaded(true); - // }) - // .catch(err => { - // openSnack("Failed to load"); - // }); - // } - // }, [loadKeys, openSnack, taskAPI, as]); - //loads tasks from the backend database const loadTasks = useCallback(() => { if (!user.id()) return; @@ -324,7 +293,6 @@ export default function TaskViewer(props: ViewProps) { loadTasks()} @@ -385,7 +353,6 @@ export default function TaskViewer(props: ViewProps) { loadTasks()} @@ -437,18 +404,10 @@ export default function TaskViewer(props: ViewProps) { { - // if (r) { - // if (drawerView) { - // loadMultitask(); - // } else { - // loadTasks(); - // } - // } loadTasks(); setEditTask(undefined); setNewTaskDialog(false); @@ -464,8 +423,8 @@ export default function TaskViewer(props: ViewProps) { }} completeTask={markComplete} deleteTask={deleteTask} - keys={[]} - types={[]} + keys={keys ?? []} + types={types ?? []} /> } diff --git a/src/tasks/taskActions.tsx b/src/tasks/taskActions.tsx index d55590b..116cdec 100644 --- a/src/tasks/taskActions.tsx +++ b/src/tasks/taskActions.tsx @@ -224,6 +224,7 @@ export default function TaskActions(props: Props) { return ( ) => setAnchorEl(event.currentTarget)}> diff --git a/src/teams/ObjectTeams.tsx b/src/teams/ObjectTeams.tsx index 595143f..9c80fbc 100644 --- a/src/teams/ObjectTeams.tsx +++ b/src/teams/ObjectTeams.tsx @@ -41,6 +41,8 @@ import { useNavigate } from "react-router-dom"; import { makeStyles } from "@mui/styles"; import { Share, RemoveCircle as RemoveUserIcon } from "@mui/icons-material"; import CancelSubmit from "common/CancelSubmit"; +import { permissionToString } from "pbHelpers/Permission"; +import { PermissionChanges } from "providers/pond/permissionAPI"; const useStyles = makeStyles((theme: Theme) => { const avatarBG = theme.palette.secondary["700" as keyof PaletteColor]; @@ -188,7 +190,21 @@ export default function ObjectTeams(props: Props) { }; const submit = () => { - permissionAPI + if(keys && types){ + let changes: PermissionChanges[] = [] + users.forEach((user: Team) => { + changes.push({ + key: user.id(), + permissions: user.permissions.map(permission => permissionToString(permission)) + }); + }); + permissionAPI.updateObjectPermissions(scope.key, scope.kind, "team", changes, keys, types).then(resp => { + console.log("no error") + }).catch(err => { + console.log("error") + }) + }else{ + permissionAPI .updatePermissions(scope, users) .then((_response: any) => { success("Users were sucessfully updated for " + label); @@ -201,10 +217,13 @@ export default function ObjectTeams(props: Props) { }) .catch((err: any) => { err.response.data.error - ? warning(err.response.data.error) - : error("Error occured when updating users for " + label); + ? warning(err.response.data.error) + : error("Error occured when updating users for " + label); close(); }); + + } + }; const changeUserPermissions = (user: pond.ITeam) => (event: any) => { From 9d41499c27b323685376cbcc89a511c0ccb81b59 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 31 Mar 2026 15:37:56 -0600 Subject: [PATCH 024/146] 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 --- src/pages/Team.tsx | 1 + src/teams/ObjectTeams.tsx | 22 +++++++++++++++------- src/user/ObjectUsers.tsx | 16 ++++++++++++---- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/pages/Team.tsx b/src/pages/Team.tsx index 593b152..f17d965 100644 --- a/src/pages/Team.tsx +++ b/src/pages/Team.tsx @@ -247,6 +247,7 @@ export default function TeamPage() { closeDialogCallback={() => setUserDialog(false)} refreshCallback={() => {}} useImitation={false} + shareLabel="Add Team Member +" /> diff --git a/src/teams/ObjectTeams.tsx b/src/teams/ObjectTeams.tsx index 9c80fbc..2e0c610 100644 --- a/src/teams/ObjectTeams.tsx +++ b/src/teams/ObjectTeams.tsx @@ -104,6 +104,7 @@ interface Props { cardMode?: boolean; keys?: string[]; types?: string[]; + shareLabel?: string; // a custom label to pass in that will replace the share icon } export default function ObjectTeams(props: Props) { @@ -126,7 +127,8 @@ export default function ObjectTeams(props: Props) { dialog, cardMode, keys, - types + types, + shareLabel } = props; const prevPermissions = usePrevious(permissions); const prevIsDialogOpen = usePrevious(isDialogOpen); @@ -322,12 +324,18 @@ export default function ObjectTeams(props: Props) { {canShare && ( - - - + {shareLabel ? + + : + + + + } )} diff --git a/src/user/ObjectUsers.tsx b/src/user/ObjectUsers.tsx index e3221c2..981a838 100644 --- a/src/user/ObjectUsers.tsx +++ b/src/user/ObjectUsers.tsx @@ -108,6 +108,7 @@ interface Props { dialog?: string; cardMode?: boolean; useImitation?: boolean; + shareLabel?: string; // s string to replace the share icon button with a labelled button } export default function ObjectUsers(props: Props) { @@ -127,8 +128,9 @@ export default function ObjectUsers(props: Props) { refreshCallback, userCallback, dialog, - cardMode + cardMode, //useImitation + shareLabel } = props; const prevPermissions = usePrevious(permissions); const prevIsDialogOpen = usePrevious(isDialogOpen); @@ -367,11 +369,17 @@ export default function ObjectUsers(props: Props) { {canShare && ( - + {shareLabel} + + : + - - + + + } )} From 52c8a02af3f8fe33594b1c9d6f0eb7032b11c65e Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 1 Apr 2026 10:42:16 -0600 Subject: [PATCH 025/146] 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 --- src/bin/BinConditioningInteraction.tsx | 22 +++++++++---------- src/bin/GrainNodeInteractions.tsx | 21 +++++++++++++++--- src/bin/conditioning/modeChangeDialog.tsx | 16 ++++++++++---- src/charts/MeasurementsChart.tsx | 8 ++++--- src/component/ComponentForm.tsx | 18 ++++++++++----- src/component/ExportDataSettings.tsx | 6 +++-- src/gate/GateGraphs.tsx | 4 ++-- src/interactions/InteractionSettings.tsx | 12 ++++++---- src/interactions/InteractionsOverview.tsx | 8 +++---- src/models/CO2.ts | 2 -- src/models/UnitMeasurement.ts | 2 +- src/objects/objectInteractions/Alerts.tsx | 4 +++- src/objects/objectInteractions/Controls.tsx | 4 +++- .../NewObjectInteraction.tsx | 8 ++++--- src/pages/Bins.tsx | 6 ++++- src/pbHelpers/ComponentType.tsx | 20 +++++++++++++---- src/pbHelpers/Interaction.ts | 7 +++--- src/pbHelpers/MeasurementDescriber.ts | 5 +++-- 18 files changed, 117 insertions(+), 56 deletions(-) diff --git a/src/bin/BinConditioningInteraction.tsx b/src/bin/BinConditioningInteraction.tsx index 0a7e694..d729462 100644 --- a/src/bin/BinConditioningInteraction.tsx +++ b/src/bin/BinConditioningInteraction.tsx @@ -103,7 +103,7 @@ export default function BinConditioningInteraction(props: Props) { let sliderVals: Map = new Map(); let sliderMarks: Map = 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)); }); @@ -173,17 +173,17 @@ export default function BinConditioningInteraction(props: Props) { return ( {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 (user.tempUnit() === 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 ( - {interactionConditionText(source, condition, false)} + {interactionConditionText(source, condition, false, user)} (); const [nodeMoist, setNodeMoist] = useState(); 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(); diff --git a/src/bin/conditioning/modeChangeDialog.tsx b/src/bin/conditioning/modeChangeDialog.tsx index cc17448..ef2dfbd 100644 --- a/src/bin/conditioning/modeChangeDialog.tsx +++ b/src/bin/conditioning/modeChangeDialog.tsx @@ -288,7 +288,9 @@ if (!selectedDevice) return; describeMeasurement( quack.MeasurementType.MEASUREMENT_TYPE_PERCENT, sensor.settings.type, - sensor.settings.subtype + sensor.settings.subtype, + undefined, + user ).toStored(hum) ) }); @@ -301,7 +303,9 @@ if (!selectedDevice) return; 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({ @@ -385,7 +389,9 @@ 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); @@ -397,7 +403,9 @@ if (!selectedDevice) return; 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({ diff --git a/src/charts/MeasurementsChart.tsx b/src/charts/MeasurementsChart.tsx index 1baeea6..c4a5d8b 100644 --- a/src/charts/MeasurementsChart.tsx +++ b/src/charts/MeasurementsChart.tsx @@ -67,7 +67,7 @@ export default function MeasurementsChart(props: Props) { const [currentMax, setCurrentMax] = useState("dataMax"); const [newChart, setNewChart] = useState(false); const [xDomain, setXDomain] = useState(["dataMin", "dataMax"]); - const [{ showErrors }, dispatch] = useGlobalState(); + const [{ showErrors, user }, dispatch] = useGlobalState(); const isMobile = useMobile(); // const [zoomStart, setZoomStart] = useState(); // const [zoomEnd, setZoomEnd] = useState(); @@ -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( ) @@ -823,7 +831,7 @@ export default function ComponentForm(props: Props) { const describeSource = (measurementType: quack.MeasurementType): MeasurementDescriber => { const { component } = form; - return describeMeasurement(measurementType, component.type(), component.subType()); + return describeMeasurement(measurementType, component.type(), component.subType(), undefined, user); }; const availableMeasurementTypes = (type: quack.ComponentType): quack.MeasurementType[] => { diff --git a/src/component/ExportDataSettings.tsx b/src/component/ExportDataSettings.tsx index 0a310ba..a17cdc0 100644 --- a/src/component/ExportDataSettings.tsx +++ b/src/component/ExportDataSettings.tsx @@ -210,7 +210,7 @@ class ExportDataSettings extends React.Component { // } formatMeasurements(measurements: Array): Promise> { - const { device, component } = this.props; + const { device, component, user } = this.props; return new Promise(function(resolve, reject) { if (!measurements) reject("Invalid measurements"); @@ -237,7 +237,9 @@ class ExportDataSettings extends React.Component { let unit = describeMeasurement( componentMeasurement.measurementType, component.settings.type, - component.settings.subtype + component.settings.subtype, + undefined, + user ).unit(); let key = componentMeasurement.label + " (" + unit + ")"; row[key] = componentMeasurement.extract(measurement.measurement); diff --git a/src/gate/GateGraphs.tsx b/src/gate/GateGraphs.tsx index b2ec455..5a3a039 100644 --- a/src/gate/GateGraphs.tsx +++ b/src/gate/GateGraphs.tsx @@ -282,13 +282,13 @@ export default function GateGraphs(props: Props) { if (um.values[0] && um.values[0].values.length > 1) { return areaGraph( um, - describeMeasurement(um.type, component.type(), component.subType()), + describeMeasurement(um.type, component.type(), component.subType(), undefined, user), component.settings.smoothingAverages ); } else { return lineGraph( um, - describeMeasurement(um.type, component.type(), component.subType()), + describeMeasurement(um.type, component.type(), component.subType(), undefined, user), component.settings.smoothingAverages ); } diff --git a/src/interactions/InteractionSettings.tsx b/src/interactions/InteractionSettings.tsx index 87bdf98..4f2a2d3 100644 --- a/src/interactions/InteractionSettings.tsx +++ b/src/interactions/InteractionSettings.tsx @@ -195,7 +195,9 @@ export default function InteractionSettings(props: Props) { let value = describeMeasurement( condition.measurementType, or(initialComponent, Component.create()).settings.type, - or(initialComponent, Component.create()).settings.subtype + or(initialComponent, Component.create()).settings.subtype, + undefined, + user ).toDisplay(condition.value); rawConditionValues.push(value.toString()); }); @@ -425,7 +427,7 @@ export default function InteractionSettings(props: Props) { mappedComponents.get(componentIDToString(interaction.settings.source)), Component.create() ); - return describeMeasurement(measurementType, source.settings.type, source.settings.subtype); + return describeMeasurement(measurementType, source.settings.type, source.settings.subtype, undefined, user); }; const describeSink = (): MeasurementDescriber => { @@ -433,7 +435,9 @@ export default function InteractionSettings(props: Props) { return describeMeasurement( Measurement.boolean, or(sink, Component.create()).settings.type, - or(sink, Component.create()).settings.subtype + or(sink, Component.create()).settings.subtype, + undefined, + user ); }; @@ -861,7 +865,7 @@ export default function InteractionSettings(props: Props) { const conditionGroup = (index: number) => { if (index >= numConditions) return; let measurementType = interaction.settings.conditions[index].measurementType; - let source = describeSource(measurementType); + let source = describeSource(measurementType, ); let isBoolean = isBooleanValue(index); return ( diff --git a/src/interactions/InteractionsOverview.tsx b/src/interactions/InteractionsOverview.tsx index 127f2a6..600c88a 100644 --- a/src/interactions/InteractionsOverview.tsx +++ b/src/interactions/InteractionsOverview.tsx @@ -71,7 +71,7 @@ interface Props { export default function InteractionsOverview(props: Props) { const classes = useStyles(); - const [{as}] = useGlobalState(); + const [{as, user}] = useGlobalState(); const interactionsAPI = useInteractionsAPI(); const { success, error } = useSnackbar(); const { device, component, components, permissions, refreshCallback } = props; @@ -118,12 +118,12 @@ export default function InteractionsOverview(props: Props) { if (!conditions) return items; conditions.forEach((condition: pond.IInteractionCondition, conditionIndex: number) => { let measurementType = condition.measurementType; - let measurement = describeMeasurement(condition.measurementType, sourceType, sourceSubtype); + let measurement = describeMeasurement(condition.measurementType, sourceType, sourceSubtype, undefined, user); items.push( - {interactionConditionText(source, condition, conditionIndex > 0)} + {interactionConditionText(source, condition, conditionIndex > 0, user)} @@ -185,7 +185,7 @@ export default function InteractionsOverview(props: Props) { let condition = conditions[conditionIndex]; let sourceType = source && source.settings.type; let sourceSubtype = source && source.settings.subtype; - let describer = describeMeasurement(condition.measurementType, sourceType, sourceSubtype); + let describer = describeMeasurement(condition.measurementType, sourceType, sourceSubtype, undefined, user); condition.value = describer.toStored(value); updatedDirtyInteractions.set(interactionIndex, true); } diff --git a/src/models/CO2.ts b/src/models/CO2.ts index 62e5d73..fcb1ed5 100644 --- a/src/models/CO2.ts +++ b/src/models/CO2.ts @@ -3,8 +3,6 @@ import { quack } from "protobuf-ts/pond"; import { or } from "utils/types"; import { clone, cloneDeep } from "lodash"; import { Component } from "models"; -import { describeMeasurement } from "pbHelpers/MeasurementDescriber"; -import { extractGrainCable } from "pbHelpers/ComponentTypes"; export class CO2 { public settings: pond.ComponentSettings = pond.ComponentSettings.create(); diff --git a/src/models/UnitMeasurement.ts b/src/models/UnitMeasurement.ts index d33c17d..9fc3a42 100644 --- a/src/models/UnitMeasurement.ts +++ b/src/models/UnitMeasurement.ts @@ -32,7 +32,7 @@ export class UnitMeasurement { public static create(pb?: pond.UnitMeasurementsForComponent, user?: User): UnitMeasurement { let my = new UnitMeasurement(); if (pb) { - let describer = describeMeasurement(pb.type, pb.componentType); + let describer = describeMeasurement(pb.type, pb.componentType, undefined, undefined, user); let values = pb.values; if (user) { values = unitConversion(pb.values, pb.type, user); diff --git a/src/objects/objectInteractions/Alerts.tsx b/src/objects/objectInteractions/Alerts.tsx index 8546363..01f5bec 100644 --- a/src/objects/objectInteractions/Alerts.tsx +++ b/src/objects/objectInteractions/Alerts.tsx @@ -6,6 +6,7 @@ import { pond, quack } from "protobuf-ts/pond"; import React, { useState } from "react"; import NewObjectInteraction from "./NewObjectInteraction"; import { Option } from "common/SearchSelect"; +import { useGlobalState } from "providers"; export interface Alert { conditions: pond.InteractionCondition[]; @@ -20,10 +21,11 @@ interface Props { export default function Alerts(props: Props){ const {alerts, addNew, permissions} = props + const [{user}] = useGlobalState(); // const [openNewAlert, setOpenNewAlert] = useState(false) const readableCondition = (condition: pond.InteractionCondition) => { - let describer = describeMeasurement(condition.measurementType); + let describer = describeMeasurement(condition.measurementType, undefined, undefined, undefined, user); let type = describer.label(); let comparison = condition.comparison === quack.RelationalOperator.RELATIONAL_OPERATOR_EQUAL_TO diff --git a/src/objects/objectInteractions/Controls.tsx b/src/objects/objectInteractions/Controls.tsx index 0346c87..6f3f817 100644 --- a/src/objects/objectInteractions/Controls.tsx +++ b/src/objects/objectInteractions/Controls.tsx @@ -4,6 +4,7 @@ import { Component, Device, Interaction } from "models"; import { interactionResultText } from "pbHelpers/Interaction"; import { describeMeasurement } from "pbHelpers/MeasurementDescriber"; import { pond, quack } from "protobuf-ts/pond"; +import { useGlobalState } from "providers"; import React, { useEffect, useState } from "react"; export interface Control { @@ -23,6 +24,7 @@ interface Props { export default function Controls(props: Props){ const { devices, controls, addNew, permissions } = props + const [{user}] = useGlobalState(); const [deviceControls, setDeviceControls] = useState>(new Map()) const [openDeviceMenu, setOpenDeviceMenu] = useState(false) const [anchor, setAnchor] = useState(null) @@ -45,7 +47,7 @@ export default function Controls(props: Props){ let conditions: string = "" let firstSource = control.sourceComponents[0] control.conditions.forEach((condition, index) => { - let describer = describeMeasurement(condition.measurementType, firstSource.type(), firstSource.subType()); + let describer = describeMeasurement(condition.measurementType, firstSource.type(), firstSource.subType(), undefined, user); let type = describer.label(); let comparison = condition.comparison === quack.RelationalOperator.RELATIONAL_OPERATOR_EQUAL_TO diff --git a/src/objects/objectInteractions/NewObjectInteraction.tsx b/src/objects/objectInteractions/NewObjectInteraction.tsx index 64a63b0..40aed71 100644 --- a/src/objects/objectInteractions/NewObjectInteraction.tsx +++ b/src/objects/objectInteractions/NewObjectInteraction.tsx @@ -64,7 +64,7 @@ const useStyles = makeStyles((theme: Theme) => { export default function NewObjectInteraction(props: Props){ const { open, onClose, device, linkedComponents, componentDevices } = props const classes = useStyles() - const [{ as }] = useGlobalState(); + const [{ as, user }] = useGlobalState(); const {openSnack} = useSnackbar(); const interactionsAPI = useInteractionsAPI(); const [newAlertComponentType, setNewAlertComponentType] = useState( @@ -249,7 +249,7 @@ export default function NewObjectInteraction(props: Props){ const measurementTypeMenuItems = () => { return availableMeasurementTypes(newAlertComponentType).map(measurementType => ( - {describeMeasurement(measurementType).label()} + {describeMeasurement(measurementType, undefined, undefined, undefined, user).label()} )); }; @@ -531,7 +531,9 @@ export default function NewObjectInteraction(props: Props){ return describeMeasurement( Measurement.boolean, or(sink, Component.create()).settings.type, - or(sink, Component.create()).settings.subtype + or(sink, Component.create()).settings.subtype, + undefined, + user ); }; diff --git a/src/pages/Bins.tsx b/src/pages/Bins.tsx index ad30d23..7529f3f 100644 --- a/src/pages/Bins.tsx +++ b/src/pages/Bins.tsx @@ -569,7 +569,11 @@ export default function Bins(props: Props) { measurementType: quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE, comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN, value: describeMeasurement( - quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE + quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE, + undefined, + undefined, + undefined, + user ).toStored(bin.settings.highTemp) }), componentLocation: quack.ComponentID.create({ diff --git a/src/pbHelpers/ComponentType.tsx b/src/pbHelpers/ComponentType.tsx index 03be638..047c852 100644 --- a/src/pbHelpers/ComponentType.tsx +++ b/src/pbHelpers/ComponentType.tsx @@ -236,16 +236,16 @@ export function unitMeasurementSummary( ): Summary { let vals: string[] = []; convertedMeasurement.values.forEach(val => { - vals.push(val + describer.unit()); + vals.push(val + convertedMeasurement.unit); }); let v = vals.join(", "); if (describer.enumerations().length > 0) { v = describer.enumerations()[parseFloat(vals[0])]; } return { - colour: describer.colour(), - label: describer.label(), - type: describer.type(), + colour: convertedMeasurement.colour, + label: convertedMeasurement.label, + type: convertedMeasurement.type, value: v, error: convertedMeasurement.error }; @@ -258,6 +258,8 @@ export function unitMeasurementSummaries( ): Summary[] { let summaries: Summary[] = []; measurements.measurementsFor.forEach(measurement => { + //the only reason the user doesnt need to be passed in here is because it is only used for enumerations, + //things like the unit and colour are in the measurement itself at this point let describer = describeMeasurement(measurement.type, compType, subtype); summaries.push(unitMeasurementSummary(measurement, describer)); }); @@ -530,6 +532,7 @@ export function simpleLineChartData( } }); } + console.log(lineData) return lineData; } @@ -945,6 +948,15 @@ function getOverlayAreas( return overlayAreas; } + +/** + * this function adds lines to victory graphs for interactions on the component + * DEPRECATED: we now use Recharts for our graphs + * @param componentType + * @param subtype + * @param interactionCondition + * @returns + */ function createInteractionLine( componentType: quack.ComponentType, subtype: number, diff --git a/src/pbHelpers/Interaction.ts b/src/pbHelpers/Interaction.ts index 5b91d00..f48bd73 100644 --- a/src/pbHelpers/Interaction.ts +++ b/src/pbHelpers/Interaction.ts @@ -1,4 +1,4 @@ -import { Interaction, Component } from "models"; +import { Interaction, Component, User } from "models"; import { pond } from "protobuf-ts/pond"; import { quack } from "protobuf-ts/quack"; import { or, notNull } from "utils/types"; @@ -172,7 +172,8 @@ export const interactionResultText = (interaction: Interaction, sink?: Component export const interactionConditionText = ( source: Component | undefined, condition: pond.IInteractionCondition, - and: boolean + and: boolean, + user?: User ) => { let comparison = "is exactly"; switch (condition.comparison) { @@ -189,7 +190,7 @@ export const interactionConditionText = ( if (!condition.measurementType) return "Unknown " + comparison + " " + condition.value; let sourceType = source && source.settings.type; let sourceSubtype = source && source.settings.subtype; - let measurement = describeMeasurement(condition.measurementType, sourceType, sourceSubtype); + let measurement = describeMeasurement(condition.measurementType, sourceType, sourceSubtype, undefined, user); return ( (and ? "and " : "") + measurement.label() + diff --git a/src/pbHelpers/MeasurementDescriber.ts b/src/pbHelpers/MeasurementDescriber.ts index 169a7d2..947c545 100644 --- a/src/pbHelpers/MeasurementDescriber.ts +++ b/src/pbHelpers/MeasurementDescriber.ts @@ -694,11 +694,12 @@ export function describeMeasurement( componentType: quack.ComponentType | null | undefined = quack.ComponentType .COMPONENT_TYPE_INVALID, componentSubtype: number = 0, - product?: pond.DeviceProduct + product?: pond.DeviceProduct, + user?: User ): MeasurementDescriber { measurementType = measurementType ? measurementType : quack.MeasurementType.MEASUREMENT_TYPE_INVALID; componentType = componentType ? componentType : quack.ComponentType.COMPONENT_TYPE_INVALID; - return new MeasurementDescriber(measurementType, componentType, componentSubtype, product); + return new MeasurementDescriber(measurementType, componentType, componentSubtype, product, user); } From 52db64a8c6bb8c493290fa08b3107ac27c27f601 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 1 Apr 2026 11:04:33 -0600 Subject: [PATCH 026/146] missed a file --- src/objectHeater/ObjectHeaterCharts.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/objectHeater/ObjectHeaterCharts.tsx b/src/objectHeater/ObjectHeaterCharts.tsx index dd117e9..04e2e9a 100644 --- a/src/objectHeater/ObjectHeaterCharts.tsx +++ b/src/objectHeater/ObjectHeaterCharts.tsx @@ -216,12 +216,16 @@ export default function ObjectHeaterCharts(props: Props) { let tempDescriber = describeMeasurement( quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE, tempComponent.type(), - tempComponent.subType() + tempComponent.subType(), + undefined, + user ); let heaterDescriber = describeMeasurement( quack.MeasurementType.MEASUREMENT_TYPE_BOOLEAN, heaterComponent.type(), - heaterComponent.subType() + heaterComponent.subType(), + undefined, + user ); let tempData: LineData[] = []; heaterTemps.forEach(ht => { @@ -588,7 +592,9 @@ export default function ObjectHeaterCharts(props: Props) { describer={describeMeasurement( quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE, tempHum.type(), - tempHum.subType() + tempHum.subType(), + undefined, + user )} lineData={ambientTemps} numLines={1} From 324ce7807fd00334dc48854a57a65325806db76b Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 1 Apr 2026 11:05:21 -0600 Subject: [PATCH 027/146] missed some more uses of measurement describer --- src/bin/graphs/BinComponentGraph.tsx | 4 ++-- src/device/DevicePresetsFromPicker.tsx | 16 ++++++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/bin/graphs/BinComponentGraph.tsx b/src/bin/graphs/BinComponentGraph.tsx index 3c5a625..07a9325 100644 --- a/src/bin/graphs/BinComponentGraph.tsx +++ b/src/bin/graphs/BinComponentGraph.tsx @@ -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 ) )} diff --git a/src/device/DevicePresetsFromPicker.tsx b/src/device/DevicePresetsFromPicker.tsx index 5faaa1b..a22991b 100644 --- a/src/device/DevicePresetsFromPicker.tsx +++ b/src/device/DevicePresetsFromPicker.tsx @@ -438,7 +438,9 @@ export default function DevicePresets(props: DevicePresetsProps) { value: describeMeasurement( quack.MeasurementType.MEASUREMENT_TYPE_PERCENT, plenum.settings.type, - plenum.settings.subtype + plenum.settings.subtype, + undefined, + user ).toStored(humidityPreset) }); conditions.push(fanConditionOne); @@ -450,7 +452,9 @@ export default function DevicePresets(props: DevicePresetsProps) { let tempVal = describeMeasurement( quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE, plenum.settings.type, - plenum.settings.subtype + plenum.settings.subtype, + undefined, + user ).toStored(tempPreset); let fanConditionTwo = pond.InteractionCondition.create({ @@ -532,7 +536,9 @@ export default function DevicePresets(props: DevicePresetsProps) { describeMeasurement( quack.MeasurementType.MEASUREMENT_TYPE_PERCENT, plenum.settings.type, - plenum.settings.subtype + plenum.settings.subtype, + undefined, + user ).toStored(hum) ) }); @@ -546,7 +552,9 @@ export default function DevicePresets(props: DevicePresetsProps) { let tempVal = describeMeasurement( quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE, plenum.settings.type, - plenum.settings.subtype + plenum.settings.subtype, + undefined, + user ).toStored(temp); let tempCondition = pond.InteractionCondition.create({ From bd15f4a15fd518821449f78cd75a2c1d42ff9e82 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 1 Apr 2026 15:07:06 -0600 Subject: [PATCH 028/146] 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 --- src/tasks/TaskSettings.tsx | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/tasks/TaskSettings.tsx b/src/tasks/TaskSettings.tsx index dcc6b91..c2b92ee 100644 --- a/src/tasks/TaskSettings.tsx +++ b/src/tasks/TaskSettings.tsx @@ -2,10 +2,12 @@ import { Avatar, Box, Button, + Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, + FormControlLabel, Grid2 as Grid, InputAdornment, MenuItem, @@ -15,9 +17,9 @@ import moment from "moment"; import React, { useState } from "react"; import { pond } from "protobuf-ts/pond"; import { useGlobalState, useTaskAPI } from "providers"; -import { useSnackbar, useUserAPI } from "hooks"; +import { usePermissionAPI, useSnackbar, useUserAPI } from "hooks"; import { useEffect } from "react"; -import { Task, teamScope, User } from "models"; +import { Task, teamScope, User, userScope } from "models"; import ColourPicker from "common/ColourPicker"; import ResponsiveDialog from "common/ResponsiveDialog"; @@ -46,13 +48,15 @@ export default function TaskSettings(props: Props) { const [worker, setWorker] = useState(user.id()); const [colour, setColour] = useState(""); const taskAPI = useTaskAPI(); + const userAPI = useUserAPI(); + const permissionAPI = usePermissionAPI(); const { openSnack } = useSnackbar(); const [cost, setCost] = useState(""); const [secondaryCost, setSecondaryCost] = useState(""); const [seedCost, setSeedCost] = useState(""); const [poundPerAcre, setPoundPerAcre] = useState(0); - const userAPI = useUserAPI(); const [users, setUsers] = useState([]); + const [shareToCreator, setShareToCreator] = useState(true) useEffect(() => { if (props.task) { @@ -123,6 +127,22 @@ export default function TaskSettings(props: Props) { taskAPI .addTask(taskSettings, as, props.keys, props.types) .then(resp => { + console.log(resp) + if(shareToCreator && resp.data.task){ + let parentScope = userScope(user.id()) + if(as){ + parentScope = teamScope(as) + } + + permissionAPI.shareObjectByKey( + {key: resp.data.task, kind: "task"}, + parentScope.key, + parentScope.kind, + [pond.Permission.PERMISSION_READ, pond.Permission.PERMISSION_WRITE, pond.Permission.PERMISSION_USERS, pond.Permission.PERMISSION_SHARE], + props.keys, + props.types + ) + } props.onClose(true); }) .catch(err => { @@ -319,6 +339,18 @@ export default function TaskSettings(props: Props) { value={endTime} onChange={e => setEndTime(e.target.value)} /> + {props.keys && props.types && !props.task && + setShareToCreator(e.target.checked)} + color="primary" + /> + } + label={"Share task with " + (as ? "team" : "user")} + /> + } Colour setColour(color)} /> From 3d1c11646f102ace6c3af045bf08c95e863055f2 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 1 Apr 2026 16:08:11 -0600 Subject: [PATCH 029/146] updating the readme steps for creating a new whitelabel --- README.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index b7a7555..8528fdc 100644 --- a/README.md +++ b/README.md @@ -19,28 +19,29 @@ Steps to add a new white label - add a login button on their website point to `/login` of their custom subdomain (ex: dashboard.example.com/login) -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) +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 -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 +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 +4. Enable the white-label sub-domain for CORS in the backend by adding it to the allowed origins http/server.go -7. Create a new `Auth0` application for the client + +1. Create a new `Auth0` application for the client - 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) - 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 From 89a83d04dd1dd6c15cc6ebd8e28ebfff87574904 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Thu, 2 Apr 2026 11:43:43 -0600 Subject: [PATCH 030/146] set up the bones for the whitelabel, just need to generate the icons from a logo once we get it --- .env | 1 + README.md | 14 +++++++++----- src/navigation/SideNavigator.tsx | 3 ++- src/services/whiteLabel.ts | 29 ++++++++++++++++++++++++++++- 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/.env b/.env index 9fdc8f3..90ef699 100644 --- a/.env +++ b/.env @@ -14,6 +14,7 @@ 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 #Branding (Default theme) VITE_APP_WEBSITE_TITLE="Adaptive Dashboard" diff --git a/README.md b/README.md index 8528fdc..4a47964 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,10 @@ 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 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 @@ -29,22 +32,23 @@ Steps to add a new white label 4. If there are any pages specific to the whitelabel be sure to update the side and bottom navigator +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 -4. Enable the white-label sub-domain for CORS in the backend by adding it to the allowed origins http/server.go -1. Create a new `Auth0` application for the client +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/)) -- 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 If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: diff --git a/src/navigation/SideNavigator.tsx b/src/navigation/SideNavigator.tsx index 6bab36c..efc3635 100644 --- a/src/navigation/SideNavigator.tsx +++ b/src/navigation/SideNavigator.tsx @@ -27,6 +27,7 @@ import { IsAdaptiveAgriculture, // hasTutorialPlaylist, IsAdCon, + IsIntellifarms, IsMiPCA, // isBXT, IsMiVent, @@ -165,7 +166,7 @@ export default function SideNavigator(props: Props) { const authenticatedSideMenu = () => { const isMiVent = IsMiVent(); - const isAg = IsAdaptiveAgriculture() + const isAg = IsAdaptiveAgriculture() || IsIntellifarms() const isStreamline = IsStreamline() const isOmni = IsOmniAir() const isMiPCA = IsMiPCA() diff --git a/src/services/whiteLabel.ts b/src/services/whiteLabel.ts index 29c4427..8c1f56d 100644 --- a/src/services/whiteLabel.ts +++ b/src/services/whiteLabel.ts @@ -180,6 +180,32 @@ export function IsAdaptiveAgriculture(): boolean { ); } +const INTELLIFARMS_WHITE_LABEL: WhiteLabel = { + name: "Intellifarms", + primaryColour: "", + secondaryColour: "", + signatureColour: "", + signatureAccentColour: "", + auth0ClientId: import.meta.env.VITE_AUTH0_INTELLIFARMS_CLIENT_ID, + redirectOnLogout: true, + logoutRedirectTarget: "https://myintellifarms.com", + darkLogo: "", + lightLogo: "", + transparentLogoBG: true, + blacklist: [], + docs: "Platform", + protips: protips, + homePage: "/bins" +}; + +export function IsIntellifarms(): boolean { + return ( + getName() === "Intellifarms" + // window.location.origin.includes("staging") || + // window.location.origin.includes("localhost") + ); +} + export function IsStreamline(): boolean { return ( getName() === "Streamline" @@ -324,7 +350,8 @@ const whitelabels = new Map([ ["adaptiveconstruction", ADAPTIVE_CONSTRUCTION_WHITE_LABEL], ["omniair", OMNIAIR_WHITE_LABEL], ["mipca", MIPCA_WHITE_LABEL], - ["mionetech", MIPCA_WHITE_LABEL] + ["mionetech", MIPCA_WHITE_LABEL], + ["intellifarms", INTELLIFARMS_WHITE_LABEL] ]); export function getWhitelabel(): WhiteLabel { From 605752ae02a192b4aaa117fc2757e563f2f410ce Mon Sep 17 00:00:00 2001 From: csawatzky Date: Thu, 2 Apr 2026 12:04:53 -0600 Subject: [PATCH 031/146] adjustments to navigation to use its own boolean rather than piggyback off adaptive ag --- src/navigation/BottomNavigator.tsx | 15 ++++++++------- src/navigation/SideNavigator.tsx | 17 +++++++++-------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/navigation/BottomNavigator.tsx b/src/navigation/BottomNavigator.tsx index b03a153..6395bd4 100644 --- a/src/navigation/BottomNavigator.tsx +++ b/src/navigation/BottomNavigator.tsx @@ -13,7 +13,7 @@ import BinsIcon from "products/Bindapt/BinsIcon"; import { useGlobalState } from "providers"; import { useCallback, useEffect, useState } from "react"; import { useNavigate, useLocation } from "react-router-dom"; -import { IsAdaptiveAgriculture, isBXT, IsMiVent, IsAdCon, IsOmniAir, IsMiPCA } from "services/whiteLabel"; +import { IsAdaptiveAgriculture, isBXT, IsMiVent, IsAdCon, IsOmniAir, IsMiPCA, IsIntellifarms } from "services/whiteLabel"; import FieldsIcon from "products/AgIcons/FieldsIcon"; import NexusSTIcon from "products/Construction/NexusSTIcon"; import OmniAirDeviceIcon from "products/AviationIcons/OmniAirDeviceIcon"; @@ -36,7 +36,8 @@ export default function BottomNavigator(props: Props) { const { isAuthenticated } = useAuth0(); const [{ user }] = useGlobalState(); const [route, setRoute] = useState(sideIsOpen ? "side" : ""); - const isAdaptive = IsAdaptiveAgriculture(); + const isAg = IsAdaptiveAgriculture(); + const isIntel = IsIntellifarms(); const isMiVent = IsMiVent(); const isAdCon = IsAdCon(); const isOmni = IsOmniAir(); @@ -45,7 +46,7 @@ export default function BottomNavigator(props: Props) { const reRoute = useCallback( (path: string) => { if (path === "") { - if (isAdaptive) { + if (isAg || isIntel) { return "bins"; } if (isMiVent) { @@ -74,7 +75,7 @@ export default function BottomNavigator(props: Props) { } return path; }, - [isAdaptive, isMiVent] + [isAg, isMiVent, isIntel] ); const autoDetectRoute = useCallback(() => { @@ -105,10 +106,10 @@ export default function BottomNavigator(props: Props) { const authenticatedNavigation = () => { return ( handleRouteChange(newValue)}> - {isAdaptive && ( + {isAg || isIntel && ( } value="visualFarm" /> )} - {isAdaptive && ( + {isAg || isIntel && ( } value="bins" /> )} {isAdCon && ( @@ -136,7 +137,7 @@ export default function BottomNavigator(props: Props) { ) : isAdCon ? ( diff --git a/src/navigation/SideNavigator.tsx b/src/navigation/SideNavigator.tsx index efc3635..815f30b 100644 --- a/src/navigation/SideNavigator.tsx +++ b/src/navigation/SideNavigator.tsx @@ -166,14 +166,15 @@ export default function SideNavigator(props: Props) { const authenticatedSideMenu = () => { const isMiVent = IsMiVent(); - const isAg = IsAdaptiveAgriculture() || IsIntellifarms() + const isAg = IsAdaptiveAgriculture() + const isIntel = IsIntellifarms() const isStreamline = IsStreamline() const isOmni = IsOmniAir() const isMiPCA = IsMiPCA() const isAdCon = IsAdCon() return ( - {(isAg || isStreamline || user.hasFeature("admin")) && ( + {(isAg || isIntel || isStreamline || user.hasFeature("admin")) && ( )} - {(isAg || isStreamline || user.hasFeature("admin")) && ( + {(isAg || isIntel || isStreamline || user.hasFeature("admin")) && ( )} - {(isAg || isStreamline || user.hasFeature("admin")) && ( + {(isAg || isIntel || isStreamline || user.hasFeature("admin")) && ( } - {(isAg || isStreamline || user.hasFeature("admin")) && ( + {(isAg || isIntel || isStreamline || user.hasFeature("admin")) && ( } - {(isAg || isStreamline || user.hasFeature("admin")) && ( + {(isAg || isIntel || isStreamline || user.hasFeature("admin")) && ( )} - {(isAg || isStreamline || user.hasFeature("admin")) && ( + {(isAg || isIntel || isStreamline || user.hasFeature("admin")) && ( } - {(isAg || isStreamline || user.hasFeature("admin")) && + {(isAg || isIntel || isStreamline || user.hasFeature("admin")) && Date: Wed, 8 Apr 2026 09:39:42 -0600 Subject: [PATCH 032/146] 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 --- package-lock.json | 264 ++++++++++++++++++ package.json | 3 + .../CameraControls/OrbitCameraControls.tsx | 119 ++++++++ src/3dModels/Shapes/3D/Circle.tsx | 34 +++ src/3dModels/Shapes/3D/Cone.tsx | 43 +++ src/3dModels/Shapes/3D/Cube.tsx | 40 +++ src/3dModels/Shapes/3D/Cylinder.tsx | 46 +++ src/3dModels/Shapes/BaseMesh.tsx | 87 ++++++ src/bin/3dView/Bin3dView.tsx | 74 +++++ src/bin/3dView/BinParts/BinShell.tsx | 127 +++++++++ src/bin/3dView/grainFill/GrainFillFlat.tsx | 123 ++++++++ src/models/Bin.ts | 20 ++ src/pages/Bin.tsx | 15 + 13 files changed, 995 insertions(+) create mode 100644 src/3dModels/CameraControls/OrbitCameraControls.tsx create mode 100644 src/3dModels/Shapes/3D/Circle.tsx create mode 100644 src/3dModels/Shapes/3D/Cone.tsx create mode 100644 src/3dModels/Shapes/3D/Cube.tsx create mode 100644 src/3dModels/Shapes/3D/Cylinder.tsx create mode 100644 src/3dModels/Shapes/BaseMesh.tsx create mode 100644 src/bin/3dView/Bin3dView.tsx create mode 100644 src/bin/3dView/BinParts/BinShell.tsx create mode 100644 src/bin/3dView/grainFill/GrainFillFlat.tsx diff --git a/package-lock.json b/package-lock.json index 2cd53e8..348bfdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,10 +25,12 @@ "@mui/styles": "^6.1.6", "@mui/x-date-pickers": "^7.26.0", "@react-pdf/renderer": "^4.3.0", + "@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", @@ -62,6 +64,7 @@ "react-virtualized-auto-sizer": "^1.0.25", "recharts": "^2.15.1", "semver": "^7.7.1", + "three": "^0.183.2", "victory": "^37.3.6", "weather-icons-react": "^1.2.0" }, @@ -1776,6 +1779,12 @@ } } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -3223,6 +3232,64 @@ "@react-pdf/stylesheet": "^6.1.2" } }, + "node_modules/@react-three/fiber": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz", + "integrity": "sha512-FYZZqD0UUHUswKz3LQl2Z7H24AhD14XGTsIRw3SJaXUxyfVMi+1yiZGmqTcPt/CkPpdU7rrxqcyQ1zJE5DjvIQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/react-reconciler": "^0.26.7", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^1.0.6", + "react-reconciler": "^0.27.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.21.0", + "suspend-react": "^0.1.3", + "zustand": "^3.7.1" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=18 <19", + "react-dom": ">=18 <19", + "react-native": ">=0.64", + "three": ">=0.133" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber/node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -5534,6 +5601,12 @@ "url": "https://opencollective.com/turf" } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -5930,6 +6003,15 @@ "@types/viewport-mercator-project": "*" } }, + "node_modules/@types/react-reconciler": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.26.7.tgz", + "integrity": "sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-redux": { "version": "7.1.34", "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.34.tgz", @@ -5984,6 +6066,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, "node_modules/@types/supercluster": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", @@ -5993,6 +6081,21 @@ "@types/geojson": "*" } }, + "node_modules/@types/three": { + "version": "0.183.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz", + "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~1.0.1" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -6010,6 +6113,12 @@ "gl-matrix": "^3.2.0" } }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", @@ -6450,6 +6559,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@webgpu/types": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", + "license": "BSD-3-Clause" + }, "node_modules/abs-svg-path": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", @@ -6882,6 +6997,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -8386,6 +8525,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -9211,6 +9356,26 @@ "dev": true, "license": "ISC" }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -9793,6 +9958,27 @@ "node": ">=0.10.0" } }, + "node_modules/its-fine": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", + "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.0" + } + }, + "node_modules/its-fine/node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/jackspeak": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", @@ -10414,6 +10600,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/meshoptimizer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz", + "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==", + "license": "MIT" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -11663,6 +11855,31 @@ "react-dom": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0" } }, + "node_modules/react-reconciler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz", + "integrity": "sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.21.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, + "node_modules/react-reconciler/node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/react-redux": { "version": "7.2.9", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", @@ -11767,6 +11984,21 @@ "react-dom": ">=16.6.0" } }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/react-virtualized-auto-sizer": { "version": "1.0.26", "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.26.tgz", @@ -12995,6 +13227,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, "node_modules/svg-arc-to-cubic-bezier": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz", @@ -13071,6 +13312,12 @@ "node": ">=10" } }, + "node_modules/three": { + "version": "0.183.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", + "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", + "license": "MIT" + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", @@ -15278,6 +15525,23 @@ "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", "license": "MIT" + }, + "node_modules/zustand": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz", + "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==", + "license": "MIT", + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 90ac71b..32de8f2 100644 --- a/package.json +++ b/package.json @@ -37,10 +37,12 @@ "@mui/styles": "^6.1.6", "@mui/x-date-pickers": "^7.26.0", "@react-pdf/renderer": "^4.3.0", + "@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", @@ -74,6 +76,7 @@ "react-virtualized-auto-sizer": "^1.0.25", "recharts": "^2.15.1", "semver": "^7.7.1", + "three": "^0.183.2", "victory": "^37.3.6", "weather-icons-react": "^1.2.0" }, diff --git a/src/3dModels/CameraControls/OrbitCameraControls.tsx b/src/3dModels/CameraControls/OrbitCameraControls.tsx new file mode 100644 index 0000000..20768b4 --- /dev/null +++ b/src/3dModels/CameraControls/OrbitCameraControls.tsx @@ -0,0 +1,119 @@ +import { useThree } from "@react-three/fiber"; +import { useEffect, useRef } from "react"; + +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; +} + +export default function OrbitCameraControls(props: Props) { + const { target, clampVerticalRotation, minPhi = 0.01, maxPhi = Math.PI - 0.01 } = props; + const { camera, gl } = useThree(); + + const isDragging = useRef(false); + const lastPos = useRef({ x: 0, y: 0 }); + + // Spherical coords + const spherical = useRef({ + radius: 10, + theta: 0, // horizontal + phi: Math.PI / 2, // vertical + }); + + useEffect(() => { + const canvas = gl.domElement; + + 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(x, y, z); + camera.lookAt(target?.x ?? 0, target?.y ?? 0, target?.z ?? 0); + }; + + updateCamera(); + + const handleMouseDown = (e: MouseEvent) => { + if (e.target !== canvas) return; + isDragging.current = true; + lastPos.current = { x: e.clientX, y: e.clientY }; + }; + + const handleMouseMove = (e: MouseEvent) => { + if (!isDragging.current) return; + + const deltaX = e.clientX - lastPos.current.x; + const deltaY = e.clientY - lastPos.current.y; + + spherical.current.theta -= deltaX * 0.005; + spherical.current.phi -= deltaY * 0.005; + + // Clamp vertical rotation (prevents flipping) + if (clampVerticalRotation) { + spherical.current.phi = Math.max( + minPhi, + Math.min(maxPhi, spherical.current.phi) + ); + } + + lastPos.current = { x: e.clientX, y: e.clientY }; + + updateCamera(); + }; + + const handleMouseUp = () => { + isDragging.current = false; + }; + + const handleWheel = (e: WheelEvent) => { + if (e.target !== canvas) return; + + e.preventDefault(); + + spherical.current.radius += e.deltaY * 0.01; + + // Clamp zoom + spherical.current.radius = Math.max( + 3, + Math.min(30, spherical.current.radius) + ); + + updateCamera(); + }; + + window.addEventListener("mousedown", handleMouseDown); + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + + canvas.addEventListener("wheel", handleWheel, { passive: false }); + + return () => { + window.removeEventListener("mousedown", handleMouseDown); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + + canvas.removeEventListener("wheel", handleWheel); + }; + }, [camera, gl, target, clampVerticalRotation, minPhi, maxPhi]); + + return null; +} \ No newline at end of file diff --git a/src/3dModels/Shapes/3D/Circle.tsx b/src/3dModels/Shapes/3D/Circle.tsx new file mode 100644 index 0000000..8204a29 --- /dev/null +++ b/src/3dModels/Shapes/3D/Circle.tsx @@ -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 { + 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 ; +} \ No newline at end of file diff --git a/src/3dModels/Shapes/3D/Cone.tsx b/src/3dModels/Shapes/3D/Cone.tsx new file mode 100644 index 0000000..90d3e81 --- /dev/null +++ b/src/3dModels/Shapes/3D/Cone.tsx @@ -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 { + 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 ; +} \ No newline at end of file diff --git a/src/3dModels/Shapes/3D/Cube.tsx b/src/3dModels/Shapes/3D/Cube.tsx new file mode 100644 index 0000000..94ae03b --- /dev/null +++ b/src/3dModels/Shapes/3D/Cube.tsx @@ -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 { + 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 ; +} \ No newline at end of file diff --git a/src/3dModels/Shapes/3D/Cylinder.tsx b/src/3dModels/Shapes/3D/Cylinder.tsx new file mode 100644 index 0000000..ee074df --- /dev/null +++ b/src/3dModels/Shapes/3D/Cylinder.tsx @@ -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 { + 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 ; +} \ No newline at end of file diff --git a/src/3dModels/Shapes/BaseMesh.tsx b/src/3dModels/Shapes/BaseMesh.tsx new file mode 100644 index 0000000..d83716a --- /dev/null +++ b/src/3dModels/Shapes/BaseMesh.tsx @@ -0,0 +1,87 @@ +import { Euler, Vector3, BufferGeometry, MaterialParameters, Side } from "three"; + +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; + materialOverride?: React.ReactNode; + side?: Side +} + +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 + } = props; + + const isTransparent = opacity < 1; + const buildMaterial = () => { + if (materialOverride) return materialOverride; + switch (materialType) { + case "basic": + return ( + + ); + + case "standard": + default: + return ( + + ); + } + }; + + return ( + + {/* Main surface */} + + {buildMaterial()} + + + {/* Optional wireframe overlay */} + {wireframe && ( + + + + )} + + ); +} \ No newline at end of file diff --git a/src/bin/3dView/Bin3dView.tsx b/src/bin/3dView/Bin3dView.tsx new file mode 100644 index 0000000..0f700a4 --- /dev/null +++ b/src/bin/3dView/Bin3dView.tsx @@ -0,0 +1,74 @@ +import OrbitCameraControls from "3dModels/CameraControls/OrbitCameraControls"; +import { Canvas } from "@react-three/fiber"; +import { Bin } from "models"; +import BinShell from "./BinParts/BinShell"; +import GrainFillFlat from "./grainFill/GrainFillFlat"; + +interface Props { + /** + * The bin to generate a 3D model of + */ + bin: Bin + /** + * The scale to apply to the bin dimensions + * ie 100 would make the bin 1:100 scale + * @default 100 + */ + scale: number + /** + * optional: the percent of the bin to fill using a level top, this will be used with manual and lidar controls + */ + fillPercent?: number +} + +export default function Bin3dView(props: Props){ + //this function will generate a 3D model of a bin based on its settings using multiple meshes, cylinder for the body, cone for the roof + // and either a cone for the hopper or circle for flat bottom, it is possible to also use lathe geometry for this as well, + // it might even work better because we can control the smoothness easier with it being only one mesh rather than 3 + const {bin, scale = 100, fillPercent} = props + + console.log(bin) + + return ( + + + + + {/* grain - cylinder*/} + {fillPercent !== undefined && ( + + )} + + {/* cables */} + + {/* lighting */} + + + + + + {/* */} + {/* camera controls */} + + + ) +} \ No newline at end of file diff --git a/src/bin/3dView/BinParts/BinShell.tsx b/src/bin/3dView/BinParts/BinShell.tsx new file mode 100644 index 0000000..d259bb7 --- /dev/null +++ b/src/bin/3dView/BinParts/BinShell.tsx @@ -0,0 +1,127 @@ +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 +} + +export default function BinShell(props: Props) { + const { radialSegments, binBodyColour, binMetalness, binRoughness, binOpacity, diameter, sidewallHeight, roofHeight, hopperHeight, wireframe } = 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 ( + + {/* bin roof - cone */} + + {/* bin sidewall - cylinder (open ended for the roof and bottom)*/} + + {/* bin bottom - cone -OR- circle depending on the bins shape*/} + {hopperHeight !== undefined && hopperHeight > 0 ? + + : + + } + + ) + +} \ No newline at end of file diff --git a/src/bin/3dView/grainFill/GrainFillFlat.tsx b/src/bin/3dView/grainFill/GrainFillFlat.tsx new file mode 100644 index 0000000..29e0db3 --- /dev/null +++ b/src/bin/3dView/grainFill/GrainFillFlat.tsx @@ -0,0 +1,123 @@ +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; +} + +export default function GrainFillFlat(props: Props) { + const { + diameter, + sidewallHeight, + hopperHeight = 0, + fillPercent + } = 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] + ); + + // --- material --- + const grainColour = "#fff302"; + + return ( + <> + {/* Hopper fill */} + {hopperHeight > 0 && hopperFillHeight > 0 && ( + + )} + + {/* Cylinder fill */} + {cylinderFillHeight > 0 && ( + + )} + + ); +} \ No newline at end of file diff --git a/src/models/Bin.ts b/src/models/Bin.ts index 5ad8635..7f9676d 100644 --- a/src/models/Bin.ts +++ b/src/models/Bin.ts @@ -52,6 +52,26 @@ export class Bin { return this.settings.specs?.shape; } + public diameter(): number { + return this.settings.specs?.diameterCm ?? 0; + } + + public height(): number { + return this.settings.specs?.heightCm ?? 0; + } + + public sidewallHeight(): number { + return this.settings.specs?.advancedDimensions?.sidewallHeight ?? 0; + } + + public roofHeight() : number { + return this.settings.specs?.advancedDimensions?.topConeHeight ?? 0; + } + + public hopperHeight() : number { + return this.settings.specs?.advancedDimensions?.hopperHeight ?? 0; + } + public key(): string { return this.settings.key; } diff --git a/src/pages/Bin.tsx b/src/pages/Bin.tsx index 4cbbba7..5eafbf5 100644 --- a/src/pages/Bin.tsx +++ b/src/pages/Bin.tsx @@ -21,6 +21,7 @@ import { AccordionSummary, AccordionDetails, Typography, + TextField, } from "@mui/material"; import BinActions from "bin/BinActions"; import BinHistory from "bin/BinHistory"; @@ -65,6 +66,7 @@ import ButtonGroup from "common/ButtonGroup"; import BinTransactions from "bin/BinTransactions"; import { CO2 } from "models/CO2"; import ObjectInteractions from "objects/objectInteractions/ObjectInteractions"; +import Bin3dView from "bin/3dView/Bin3dView"; interface TabPanelProps { children?: React.ReactNode; @@ -773,6 +775,8 @@ export default function Bin(props: Props) { ); }; + const [fillPercent, setFillPercent] = useState(0) + const desktopView = () => { return ( @@ -802,6 +806,17 @@ export default function Bin(props: Props) { {overview()} {preferences && binComponents(preferences)} + + + { + setFillPercent(+e.target.value) + }} + /> + + + {(bin.settings.mode === pond.BinMode.BIN_MODE_DRYING || From da5ef99a3e5bde922906a79c6dd2516e51da2fd6 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 8 Apr 2026 11:37:48 -0600 Subject: [PATCH 033/146] added the intellifarms branding to the whilelabel --- public/Intellifarms/IFND-2023-Logo-White.png | Bin 0 -> 7766 bytes public/Intellifarms/IFND-2023-Logo.png | Bin 0 -> 37147 bytes .../Intellifarms/android-chrome-192x192.png | Bin 0 -> 13834 bytes .../Intellifarms/android-chrome-512x512.png | Bin 0 -> 38128 bytes public/Intellifarms/apple-touch-icon.png | Bin 0 -> 12842 bytes public/Intellifarms/browserconfig.xml | 9 +++++++++ public/Intellifarms/favicon-16x16.png | Bin 0 -> 712 bytes public/Intellifarms/favicon-32x32.png | Bin 0 -> 1899 bytes public/Intellifarms/favicon-48x48.png | Bin 0 -> 3117 bytes public/Intellifarms/favicon.ico | Bin 0 -> 5552 bytes public/Intellifarms/manifest.json | 19 ++++++++++++++++++ public/Intellifarms/mstile-150x150.png | Bin 0 -> 10953 bytes public/Intellifarms/safari-pinned-tab.svg | 3 +++ .../Intellifarms/IFND-2023-Logo-White.png | Bin 0 -> 7766 bytes .../Intellifarms/IFND-2023-Logo.png | Bin 0 -> 37147 bytes src/services/whiteLabel.ts | 16 ++++++++------- 16 files changed, 40 insertions(+), 7 deletions(-) create mode 100644 public/Intellifarms/IFND-2023-Logo-White.png create mode 100644 public/Intellifarms/IFND-2023-Logo.png create mode 100644 public/Intellifarms/android-chrome-192x192.png create mode 100644 public/Intellifarms/android-chrome-512x512.png create mode 100644 public/Intellifarms/apple-touch-icon.png create mode 100644 public/Intellifarms/browserconfig.xml create mode 100644 public/Intellifarms/favicon-16x16.png create mode 100644 public/Intellifarms/favicon-32x32.png create mode 100644 public/Intellifarms/favicon-48x48.png create mode 100644 public/Intellifarms/favicon.ico create mode 100644 public/Intellifarms/manifest.json create mode 100644 public/Intellifarms/mstile-150x150.png create mode 100644 public/Intellifarms/safari-pinned-tab.svg create mode 100644 src/assets/whitelabels/Intellifarms/IFND-2023-Logo-White.png create mode 100644 src/assets/whitelabels/Intellifarms/IFND-2023-Logo.png diff --git a/public/Intellifarms/IFND-2023-Logo-White.png b/public/Intellifarms/IFND-2023-Logo-White.png new file mode 100644 index 0000000000000000000000000000000000000000..0afb3bf8e3eb984e5755d021f26ba40a44635d0d GIT binary patch literal 7766 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 literal 0 HcmV?d00001 diff --git a/public/Intellifarms/IFND-2023-Logo.png b/public/Intellifarms/IFND-2023-Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..11ff1c80c8c4dbb7068e345955d430020324996a GIT binary patch literal 37147 zcmeFY=RaKE_dYyGq9wtI7G(^hMHhl#$Y>*45WPi@9-UDWBU%_GQKAPCz4stmhUi3Z zL87x1c3;cK_EO* zqFcaUpy$3v$eaInX47(iKVl-6|<6~nT?gEm6@fFOShFI2o%$#B=_XG z_ta(@p||1a={2V0b1g5|$YeRCgy$_^v=ph;2aT&Ls~sw9+R5qJ*8F8 zo|5vydIcWt_NpP7+xMPIifmu|XWPe6btkx&Sax3pq_0SZTdu9nxQ(CAc@g6Q`}zOB z{y(yImH*C$h@6=l?f`>3vEy){CgIRiJ?^bAE0~;h969dA z*nex?APUp*PEzttm2X=zLy5DurQz;`$DAvnFPI(@{kw!&Mh4a<3tiAav}M0DNvD^k zflqN2Duo#-AuM^V@BO<*CMbxYYj9C=>iJI7!5+#^r<-p!Lg1^;&PLrrYr{uWufV^{ zfOy!GEXvL@gaTlzthlr_#`hV9E#WeH( zwZJwj&LtxTvmf^15txNPp1l4V+7bGHm%zCsRbZ0+EDJb>CU^GkvX98H8AZhJ(@=5< z{=aLOWzNvM&ye2l<959@Rw-9nLvBgueg7~J_! gmz%3aD5maQ(K1pZ^0W#k~nLD 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 xo=I literal 0 HcmV?d00001 diff --git a/public/Intellifarms/android-chrome-192x192.png b/public/Intellifarms/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..11f1839bc75398c87a34c7e77bbe70931e16dfbb GIT binary patch literal 13834 zcmbtbWmg=}(_MUVcU#=ugDvhFJUGGK9TtKtt_hIf9^5s!yChg}39i8d3x9rJ;d$nq znSRsLXS%zls_w1ZF`DWMm}q2Z0000}Nl{k&t?m8q1|q*bwbz_b004nGC0QvQzwEOv z{~!w6&lfP!gYOkCV;|U?PlZV^2IKKL)On*+A#l;#)-w~lAU?hbaaX)A5%ev)>mDOt zIII~WP{$BSqRFdZw<)xeD$j+)^l|J z-n?}G$+EbnrlvyTz;OCn^Z<_b|9PR=1KQxWvbqzYQ2P?NtpIRj54C(39>nJFS6bTA zY-9*_QTOI0Hp8(Xg^~YC9E$1Yp==^|mn?Q5nH``F`L+p_gA72X|Eoqnss+AH4>Xgu z)o8Tc*s0!}(=_7hm1?&D_{dlrNP{ENOF#=a8<77jTZs+u)mf~*&TIkiAyCyXF&UBM z^JM2$Jt> z#HN(O6{R8}o$I0N?_y!P8`_JZ3kLVl9g?;+`>X5$O2WwgVut?tNBE~ZNE=9f4`ut= zGIS58O|qL62g`Gh`HU{Vcxd+N@LVV3rIY?$19%TuLhk_?lZw`wDd3?p(rcxtc_ir- z8j9gkjSCAS&Dh5NgbR&GOvVqMB+R;FLXVNf(o@4kzo1 z9U1r)3af5mbOa6tzGrDP%55{**&jtu%dK$a4rc&nkBA*8g?aWKc?g2wyQ_*^N18Tq z`5=2NWDy=R5a;}50zl_EP}Z`T0B)4jp0KnuCr2UFymm^NEfvDDla2mm6mEEnJO3C? zMi5a61>ZZgkN$&6IU7Hs;oMhn1TDY#Bfbe8B#Km!mmgUk1WmJq+lqUJMq)dkgq)VW zt<5fj2L!hqMMnTs07qF-9!$*B(q8=XkvEDu>YbJO4H=AI@e7Bgt^asD1uZ2kyAV~D z$8vG8`K6EP=a0%p*a5_Q>+Z)Bltgt8fJD%etrpYK3i;TH5K;{oRqek-;qn=Sv3!Z% zDk@a{&H)K(GEb?pQvg7B2lwJ2pjVT)wf4qN*d{9Jk9PEE(4dkyehZ5ID%KIHW|ww$Gm;c8E{KOz;C0 z^5sXe!|vbPX5}%xupcvYCG%-+h{vx%IdlcDr8K^0$1Z{Jb%~luU))?8%?<0Y-i$sP z&zn5ctrIBNE~^-W3!eOlAxXDSftd6#9278fbho)WvwnCgQ_0EyqyI!Cx%|m$#B?UG zE5=Ntht8lcM;-UFgknrQ@ZIXGXVZxxGrKSf4kW6j&88Bvae!OdWS25e@_Tb%@tk9E zHtI%^&|5#OJ^^^auA7vO>zODkfo&zx1@jsSCG-rwPC(iPL`H39w>?|KcgaDphZ#lo zr>PwPH=nb4e9zA8Ln$&JG_6Ln>9smjP*({KIVgms-R$N(@!&;ZGJIPF6du^8=K(tr zu(e2rzTA5(&GK2XCa3kdm1`FPTIz%pBc0eW8@>4C(WS@|e+AnoEASEHHJTcJu+<{YC@~Z974IQ}>kQP(F}QK-HHL z6=L7lQbDhS>D;0o#8S?2?KaN2Y=h7QaEqvHEvKBHpnQD%p{?N|U$BH^w%GOZ^)_ts zz&PUZ5bz13$NlP$F2zJJ{9qoGhiQHo%vd_h55S?bLFff{GllO{nv8@XV+hQCFQn3p zM$s#Ew_W(>OU|=utX-RV15dep1x+Emj)#*tV-5ItGFH^p`|KU@xCi(|@D+1{von8< zM+mt0K3BVv*QOs$jSgo}kq2PMBMDbz|Mb0u8jg=igqd4W!oct;L$zsvp?ea$Nh!_n zdrM+|s1?Tflvm!9Hr`C}wS?~m@O(cKVK0>Bn|_U1`m6^fTx-CfV}oTrlgqF*5A&!(bI7&T<2sC%;K{-nMySc&#J1lif0iDsTWHJz?h2Ivc``T2CbxusZh>3P8G z`5W$UPsF!6!MpJBhvycN^2vJ`zDn~e*+Lw@IBk!rNek+ed)I^qS zKfk1k$C;5Hiu(F^2T_IzUvl?;vsr0mjuJTn$drtgy`s^!jzAq5nbfF8d&grX(Q7mM@<%gj_E~{;8SY38L*0hkgRjNagl`rtMsfHb1OuciTk1-nSftgeVbui=64wAaTe;ry*TzB z*6X6sx+#VbYg9UF4fL@td#1Zcf1OARH`&wviAv!zNhx~jdqrKsv??-n^kn{bu$Fl6*mk?(;RT)3WS7|+9oOUlmn*l84r7nj4k&xcVO8#5L5!l( zSLFeri5-pYsLW19@4LB13>Z~7=R?^FXQ12lMQ$B=YkG4O+t!? z3JuO=%pF`1lF?=t>}L0@_w;Qsv{0Cz;m-|1>=Vj<5doGuinGTUKcyQES&{W-G+bV+ zuiiemLPjsqN#_B&3&zMYtI)$PP9BXqIM!t7Hw*~ZpZxQc)!RzLYw<}aLi{X}EAnLl zo>yN5yPZGq!9o6{^G;o3LIxAy4bVvnXxjaiZ~NgBAv}P??&x*ZZ3GUCV#ELqKrNjU zj>3|vtktP%!QGz8na7k0%sND8{MO|{>jlv;JrXib4p!;@h3uFlN6Q{V7^;q>vsoOUjL|| zO$Q@}^fEUuV+v!%4Dhl}@R+YNkm~6ZZ0*Q5;>ZIcWzmKZqoUXD=8Cr^pXB%Wu@L)l z{v+vFmy2u{5KwbJ&KpjCZElq3+h$x#6 zMVcsC0zQX9Dv8D`{4fIH8pYzkaE3#){qccIJ93HF1X-IT%B7|%P#n0Ld5GD&_cp}d zpdpQ;YAcXRRrF?MZ>*^7^g!KA)3Son-0j)_ike}3XJZ5qG;h^RYbEfQmdc0@;Mhl@ zo&WKlKM0#wOdHr>wev^eh*~#u|1<9?92zsAQ`VP(ppDt5g6L5oW4w5NY&*I%fZH1AB_6|ZQ8J{M0$9cSXLJ&D<}*<=z6K;5WNk_X$QbnX)1y zo$!!aFWa0Hw$ z#Qyb!jn#sqJk$D3iwZ2y<~v)bPo8u%yf9`%}NWnB46R(FF{p@$Xf^WjHOJgQ10C+SQ}4RLL4?dsHin zussoT2VzE*bHJwvhf2Zp5!j@Hl3S(1F++)_9WTJFxH!@RohcMD1wuGkBDdOLjaYg& zNGt*(0S4hd;Lt#k`z=s$PsKkUXN~tJK_Qd8K3$Eeu$`LeyQJx2)gn+Pcq-wOpuofk%4+0AecL+!`S27=2qdNXH|DbNw}2acSh_jw1DjBya!a1+4AY|0^034B@Qz=pBs_VP*Ff52!eEEJ-iZ zcu=9FRja88#70~--pR-)^s7RVClX}1koEoNO5OXO)LeT*($6&9f$K}YFVBNKP7PLv z$wSzjM!u1!8w_FK3+;wNU$NhyMLL*QWjP_|=L@W}bJYcv5gNj?B(y+av#;xLt%dAz ztJu}X-wyQoghRuh!7BCL4uv@zNU2)Y6Q!b)1CnGR?=5>@ESYzDDvYgKvr7I;S6`Ql zQ|K5$`XNHa*cmhmMU4{Lef8)!jjbxd56hGqj4+>9-u;Rbfe?&PpJp$bA_&=2+Z!_+ z#QGO6p)6wD1)Hot2s_F{OiGi&RQvfCa{}79gueZr>Ka{{5HJ?=+z9;Y>qGs7#eL%n2GDeHc(&!F3^qsLuRR309 z!B50o58jIgq6^UL802BPs2qzOL%YNr+jCj>KaV*-GDgot9Ej>gdiWT>5Ic*@n3GQT z_kEt~_4)K2svP{t^Hx7l$+PfWfOdIW)s-WLKZ}r8-Zy5}j9edfTXPVyU@z&r!?Pv* zUGA+D7t-<>u0FAd2tKNjB{CZq_X7n+6AzQrFXSyPTndKKG^~k>K+(z$#&`UWt8Wt< znMtp`p}4igtzk_4gZ!5Q9TxQAzMg3y@EgK#-i*0Ic|~^}Mi734OFXWAF=I+Pf@%nd z-5@qQ|8r^VFbjqNZN8LVj?l=CjZT`3xKdMHL~lePmD24sjLC^_&)r8Z~D<)NI0`~7bIHMRTvlMiK>zCqpw~-; zh;(|Ueo6HPmXF2WmkE4zc|&Tmu^kzyDXWMNit##zN_TQ~Nfivt#y*H7E4X%Bh)DB$ zv0qihhd#*e!KheTW#{**pwBrtn_Z13hqVLf->Re=;kLq*{HFxag7Or8w761y&K*GY z3+*!?0bhFJJu0jJlH4N?w!8EnmSFm@y8M`kd<2s(Mt>g)5IcbOjmUMQlH6J|k$1P`?A(Y^mfIG;$gA`~4hf^RXZ z87OB$8SjL2sn5wGu!0+{HqK)R4KhTyk06iug;0Ss3o}!HFSqW%?Si}YkK`(KY(IKm zDQ#qdPz~}jwm=!vfk|~aL~{L4qG%*L9bNWdMh5gyXmPzYJ)KzgcSbyyW(5**NZ_5X ztE__;K~|?VRaP)#fI%9-bS#KtL5_IK$)n7Fqt0`P9_7B7_S03`F$49bFJN0alh z2g`;sU64W+!IBe3IlzVF?MI?me1Ht)_SWCu`l1wKUWobox$cwn6Pf z&)WJz2;qOhNUh*F9(S{3FKbRoWP#qN{=RK-p^Y8~A|_;7xUv>L$gW zE=L~NM%6Zcu!^eV>EHk98;3IOhdu(9Y}{nhOvMcZ-GT_7yri zes2)G14pa;c*jJ$>jshXruDQ8VL_e}4fx$nXnLuWK7yVQYCB}jWzfGLIt(@2Utsa3?1)eI&8PR))_(8BnE6K6s%`!7%%#E09`9&HS%5RMj-Aa#mCaZgyG`=- zZGM$ZgzdK7a=FE<)E5?y1m*kN>aQ;09$E;l)O74s>)1UD`fohSOmH&$P6c9ITJ-*4RmbQ|W#1WCBJtaz zxRC}hGLSPIzh(%d@KAlXei!!+An7dC)enD)1OPc{KFMY3A>8E1=z8DMw{^v5gCp!? z176k+au(P};fh*0;i2$-P_f)kFQcxVKlD#e-`xoSJVCc%>oA%iplpRuKs zgErq7A#WxWJsEz~Kx#_(e7P;*3VgutxdzaO*P!s(5(@b)c(CZZq~_v|L|BaLO|$$Y zj{tWy8L`*v@8uqZ0GS_ z9h*-Ioqp5@4a4!l6^5<)_)tFG7%Y*7AOk3jJ(L&%&!<3-2Jt$&jL8Zb?3)WOz>kAb z*6B29z~m3I<0k?yGd|v}W?k6%k%G8X*pHTD&J$)GPAnOcL$as|+tJsNGWnM(l<%8W zKz5m)W45_UuU_$sO$7XiWEWB6-o&3{{%!7Zhn%HYf^%o!Tvu1TPk%;h)qdPNDMohk zG6JjvF8jtilHN}~>Z)Fu!s*_|Ts$Fcx75dP0(MrZN#I9mERzgArxcBkx}$~e>vbl#Rk7x`uvDLEQ`E)gD{r+aDBciZzJPvSZ)H^EpwObc&;t#91-+2+)G2a znNqsPy;UsTdQR}V_yxx66hQCfl}S@x$5Vdv&9-pZ5WsQ%5*+`&*=}D*EI$s$M3NwL;H^XuH7=NqOl%P?m=gQ}@Xt1D zSQq`O#kG&PZWgX&3rD?x4pTFgcv$ADR<3% zKNPqqg2x;C0Mq@wLi?>`e``ViN}&F#q>nQVKUdQ7&>EKIWdQH|w*R1xKRSAZ69c@| z>yO&H8h1qkyHc)_GCcIOPt#Z%P%;DFf`zkmT#uxd=RK{*rjMxyUDP|2c(YtZmGj%N zGz|$+s@)zdf0LZD%Se)Iwtw+N^}I(~nAA8$y{z)%W50tS3Yb^E@decEHKWhR?=>(5 z?znjcHKkl2noly2u&J;^#LJ8A6Mx#i(TB2R!#{w&h~XYOt!v^xdrs$RYtVve^T}Ed z%Mf4F3374`$w#M$mx7V-JGk_>Z7?tltcI5;gA(17tv1Gs)z(E_;IJj7kCUwD5C)Ub zo2|?_Gf)8_xo%o#1d&lW6gbWH`QA;djBs_RTLHfiz@Q%=GPkFXY!-#V2)*BC3ojIn z|M6l!3yI7PFaMa^=Ekb$r8N;LXywm!d4egXRr9$4q9Ys9;#VE8|hx@CGqEli+ zY>GB1PtSb;^{>lU&b9Zwxt(UxJjDUVxQcE`?|R%^25-1OP(U0sHKOMVLl9mXWSZ!m zF})OThH4bRipeJk8#$*EcYBKX8(~-1@&}rQ2P*FS_qU{G=um|@Oi}DlV~VFzV`F-@|EU@?8*RztQ$Pp zz&+uv!deZVX4q+flUthI6d@HcS&v%O#Ur)#psToRm6zpSeYahP0S}OLE;M7&#?@og zhK#P|<`+QIl)*OVROnDE8vHn8vFJomubJtN0*sbOAY538_1ADnH;@E{y!qX{2keV8 zhu59k<*g{u4D5HTz@ob<6Y@Ao>Q|ra$Ap8C0&-TSk+Ikp6RiFH9Fw!n<|SLt^fbHM zG&zj?60|SKv#Q+Q^4~ z<=Kc))PG}XiWxs74&z0^D95OFVcemM42dO!*!rd@G&uN;ox@Fkh;n*Lx+_h^kiAi4Fs(ZK<!9n^jS|9QO6j88-L>3Y1J{{w$j3gq}1cb zep^4{c5_<-Vxf!ajU5Y1^%+7{hS$TvGh%6&7s2TY!d$3?L)&~V73H1EKsH}5>L;Iv zuJmzSn|Zi$N{;zD8#UwU37WikjHSN_Ew@}u3b$mzZmzzoDJhmPnET84*Xlo2H7Aq{ z4@2CUq@$&Bq2?NIb(a2MXN6StJ$*6J^zuLs85g`!0jlq98t@#*K2pw_&32{rOPF$T zqlQl6@n6>`Ps8mZ=o1COo7Q1ah zctR;N**qw~Yk%w;j`o`D1(hdx7g&Ny>{I;9OhdyE%CT1M-3sbH=&en)yGr|<(Ji5V zJl%ANo3`Uj9}@%7SWuG%LDzvU)pAch%Jt6>m(%!Ck=#b<)Th=9^vUybE!A~X(s4|v55+3uN-}(M{v9W2;?VI`e>N1V7c4R%HOA?XvIq82C&KG;lDd2W1pr}ctff13aX=7 z9VM9zUsXhBnte8#f}DMZ?90lHi%H!fQJ|we$la#C`Z>CS15D<-%l!<_aT|5S03$%)cq*hMcZDV@UtMX6u62bQ-sxPM$l%TV?3Xm!rPiSOZ>&1c+~ zn8yz~q3V4qF~>^g`h?t%!Ts=O&o6k`^@LOO+9XhQ;zzm^L8%B;mBA~M@|d%{=0z9_ zW`a)c_CeR@F-EJbDvq3xdX?jIRg6}FAqVQz#v5Ik;XG*pJEiBruRlQta!M5F+pKue7Y}WU3(0Xvj93ucgHI)=`#BK7(9|x_Qi-UOaF~> z-fm2tqSd~D@WgHt_kX-s<`#L_fpEp*Zq7MAPtg%DI?CmSeVHD2!~gWf~1l$>hIJ7Wz5$wvd%b})KyPJlRbJB~=EjP!C- zQMSEs$8+ICB&CG8)lE)Oq%o z#s}{l_o@dXXYKHh+lQiTdW?ssp90l5tE?sGPg$!3iUAKWS8b;d=2->bKlagR zxt}^b{-k{#tXc8cQC#X3pQ7HSp5NiB4Q60-LcVl~CYa~77@(HTEPSU;v}~u^i4fsm zZ8=$FSXc!gNX9612^a)|RF-e2kJco0y+?Yi6D-{vokvEiJ1p$Ik?;j=owTA-8iodG zN&>bw8}8e!a=03Ci)gxHyvtt%BJT5q+IdRwO<$uh_eXF7KtG+ksn4wm@oGL|>BY4i zen}G+a8nI+-x6_sj*RN*so2-8xpW3>oZ%I@d3>mt;h27i3?p@e(3>B_H6M6AUEJ$0 zu4xZHn`0!NtgaclX`0YjKU44hczAN{#Wg#9Lav|rf_2kHVrGW*nZ$m_k!e0rIp$?- zqvozQsfrA9f?e-|g~@yU&>x5cuPP$K%2ql6^f^6sc~akK&wfrlcx21+*Ib50j6Dn} zaqQ#CI6nOPf!6;4lm(L{1JBvc~!Y?+@Qf*&Dn9#sgopgPYzzneMJ_|+WPs}Pjw z09FjkBcOYwtK#Dir-!^L1~A{4S-NlSJSsMW^^#$uy(EGG2Z~GYv6l;UbZ@`yx}B4V z!{(KlEC*TX{`GMVwQhR;wR7z@wa$*y*VZu;k7MC~9@k|2bnIQtOja z?OJ0;2W{FummMB%e0NknRM={A#K)&q9C1&DiRepiPo2R+j;=thp!5ftn?$&Q`zb%^ zVZNbwhp_%_P4Q#g!*^~Zw;0P#uk{}k1s5fw)Qbl!&^{5!tRq39(2JtdHW2LJwLyQ$ z6{!|S!WBVvhcg}Ty;_64De#UgfqTDXR)qR_L(41K`fQ2ia<@NJt1tYMjLhUf_8hsC zu`$N{t(}Z2kULrNCI9|GM()CjO=BdH)5f;zvnVzR&Zq35(+=B&*Y@>wXGs?@f~&O0 zY27_s6(R!~u{b6(C=YspiEge@rp`5VKMg&o?xL{i| zn`h(9r^o$BI;9gw9WsCaSg!Q8sz*6pVNH;#glCE*RJ*FN6n)n-I~D7keJP z#CRi%z(L3R4zL&wfweO*KT6p7*Z5MC|YW>Z5RGMeoT=v7a4p75oQ<_se4 z^}RNucJ|Z6r{972nNF06e^=)}UBQZXArzIc@@htQy0y;DFuV$Oa zOD%|S*Mua(SIX>F7%ootdmEe$8wSXa@Aein6DZoEwU3c>Q!%6DQLJh;2Az49aH0<(o@rR#QH zF|U)@4#|bG=rVmG#Tr3pDINXXuQ%qpXD}?)_pY`h-}itF`cW9(tXzRP7G0mguI?84 zE(agt%tpg~0LxsPdLD3OJIsb(&z0(z9eyM0%{A?k*#f01gz|JfQy$C z<~-=AhhK@`9|x?i7Y{ecez07YE0pDj*H+Kn?4c;!*l>Ou`w>0GzY$&DC>~&A`esVf zz7U*m&mM&(*uA^k)N$)8yc$>qLY76>UnxCjQYV<>v@82j&QhtJU&t9FAzO${26)Cikh0r&g? znzf_75h4Rt4D;#0Z}+q4u&s@sNv#J8{o(E3lo%a^hQXpSm+}jnI}_o&&&i{3$oOBF zDZFn5@}BE#{v%g-x9Ra=gkiHwJ)f+{qDAQ}D7edH^Rz|YEd$(wK|H8L>wD&wFlyY% zBooWeM9zczR$RgZ*eslp5`}rlkb$A5skiL%U3&Sd3F9$T;ked=mT|Qb#>7#$*xqycmBKK+S54h1 z>yK@7a9vZ$`L5>CS)}$_NVFK-`K|O8q8GdKzd4tn~$@Z)aU0R&rgs3*cK-}J%#^1#VtSX zlAgX51cs*Jh;))@1jQ=)yX?Q=2~g|u+-qg{#YOo^sY;dug*-FHK;toXLmr*5Hwd6& zx`1J`A!!8H5IOGGBa8>2g~Fj1GJ!f{9S4dMVmg-ibr)VH84j+c{ZcR;rEL^c92z$o zEW6u^8e#Rb7I67Ux7moQzn4BtD0aEg_iaj-E)xZJlS^PBIJ~cm?flOlnv`~djLUiz z)j|zC*=55-IR5#U*l8g27L0OXgb8xghNQqbk3j_o&f56UfoL%ah`5lt0^H@U^k|0i zHhrBUesb#3cyr8Qve!3UBFNz2>PaYhT;DNGoZ^;teuUwe7`e_DW*BF`;azc7yk0&~ zV-#&Mlf46T*Ncl6XfX6VgE*_1SW>yrU0sgQoeS9^&)JPcHD|?Iv^0I>8_VBI{}l7P zHT5I8xORqZxW_@uP#)$$juc5o=KI3XgFx%NdXE#moCuB%LcIo?EhDzpT!Ahkz-qea zn|puB$lg-IsozWB27;g@Gy(PSCLB0+nbQ(HnKI-zo$@DbaMeBC-sIIMBkN2t$SsqNz-YH%K(UrDRY!_DwSg52}#uOF6v_M!$4> zHKn|<(5=DN#k+T0wIPkW!usR@`N>*eU+*BhVk>es&RUy!*Xy`)oP!&&Tlh0QpT{Oh zOUd}mQ1hEGv<0#!Nc@9YRXvLme{CDJ(xxo_XFN6VueXKIio@6&;I#k(t6^6P0;0Pan?TYlwd)eUTk;PhLE+B)>8T@=yF5VKLYA zP_{mspe&Tncu07G7J;k6_n-;20n#ix%*)m$j3`0DG(jSqsSqsruy_x&$Hi3ua1-2eJ{ zcU2pYzATg82p0AEM1L^hlr|+vRCbzxu}qb)5S)&?&L(kD*HIi+$29BkpD;BdMO=`_ zMlVYIjWj_5P(5-by+3tR#vm9SrbwTPy!*tjOB;{{|9U@Lgh}GAJ(~JV4q+2Hy^K9X zw1FXfWyS;?aG_CFRdv;?etU(6xh_J|sF=eAY;(iFs@VjNeXzi33YbaTEYfnFEgtu< zxvPlCL~y1E;R=q{AgDY%?|DoV^ZMN;{Uv)blpLUi>o z{+;Ibq19QmD%Ch(`@8b2@)6vxd^F}Rid~7}4 zS_Z8rio*L9FG)=JEeJ}67`4?G0}=@e*mC`*mt1-KJDEkeJFk{kCI||enMB=>eYMyly1o|e5nt~XY!Su?Dc%C5%b)_dt&+;x z*b=^j+V|nBNCr8$!N?%zRuEECFL7tKdfn?-WHk-|s+bu5R}3DQvKO8kj%W!=mwWvt zhn|cc!i2jACSc6Nhp@+Rh$ujSGziHwz!>Mwd4%ZLFwNl?MxO)f{Lmsyy!qhb&gYs6 zF8CHPv1H=AstW)27(r?QSgJwMyB1Dvc|$uvC*FVu`(F8IRo}+R1i?^)+#$=cTH`rLf@0xV97{}OhcGdPMD1Ep;v^b7edN4|3`Q#Yni#%$q9u78bIrDzAlsd?>};I&4_vrU zXT4`ENZg;>nBac^0K1=%?F!eqX=c3!ZgX-KVSzs=w}r`*z&jhg%v+PCPp(kxiQwO6 zaZIY9Fvr&zSdnflk`H@#WLXt|l@&z%U3g#FKp8jYf+Z2X$Y+Czz7sYFEP*>*H?@ms2FlCip>khP=EIo_xsNGll62}grTf{8#uQMLluR-+HKWZSU4IR!TI?C3 zxb(J)&uQpxC%DVDp8S9Uq(j<>{mnY}Hh1nnK{xG9FXF ztOeRVGwnDBBEv2L?*%0Q0Nf*2OkR@gJd-nTQc%8)eHjrKKiiz#T&wLFR4Ux>`m<*& zKy4I?N5i&vvKJ-Q!Tuyli1?fAl&JkzvQU|*Mdc0Zpj*A!MYtOTw965{GUI>(fW`yU zM+E_gUf8Am*F}Rd+}2LTm2M16FCo2}jILP|@i>Hh05`?#ZcE=N zBOEMlePHto%UH0#zVAp6B~|v((0>fvjftB|yEfNZ&Te%NL`NP2XcK5vcn1SS?IR+{ z%j!MQ;sAl=Z%SR5(sp~l6{g1oJnXQIJZcoq2Gvp}ThnM##QIl_bakk>(PY>lLnX;_ zZoS2f2<0se1OpN4>K&4i(7b!p>3$fXcdLYR?nGXU2w@~KV-$`Z83%tF`?RCaOFsx7 zzt!qB(C4lR^LIjni={EEzj-U-DF~TH-0h5rG2_wqC9aY>Q#*y&-Qf)imKd83hY6ccOm?tm2IRO( zL6qKY)EF&VtUJpo`5+=H1R_7|S+j4$hee40FqvoHqmj~l_wGH3WvS=6I>B*Qa@RXq zUk%6=6KWe&5{D2$Q#uJ*n20$7KBps{0X|N|2_MKwv{rVW6+AE9>Hj)UU{QDYKyAmG zmzJ2K9bEY8?5i=70ki1O^`&>HiAf6>FkbjKV?)%000>irsyByQ0Q9o5Uxmt0 zULBFbBN1Xlsu4#mydtVa-vS!dFmfyY5u;VymHGrB2Yq1;0U&JhZAw(dUf9|J_(n|_ zJ@$c0^++`opK)PlCMM3+w=&AvypvKZ{;HiK zr+mMYD-93^{Z*V2c~XqyJhq=~C(qukz6KO`yLyL<_ zhF8-2BQ~PHsjXVsG+Nv$g?Xz*w(rZrBN>kKDfSnM1|KbmR`JqR{1X?6>m|qoVjl&t zn~TK%00%iMOUj13tR2d|1{Vsx@8C%$NlI6i3aPJbJc~-r@%bv8SsvU>+tE8pAOaqs z$P$kBFRCFSQ;{;~PR>AE4os(!+fu#JkSl6`vSxV^VgaZZjojeq)51h*`cR4WAw%&T3}u%axJ&FA89W0je_`XM=^ zgZfe-aONX7>*OcObHf&*`yh>eSJhu%B#dQGHz9N)hzqp`Xei~S0{DGs6CbVRh9v+vTGJ;STsX$yTdzU zsLCW1iJ<^lZs4Duv!pF6@zYx7g2C>qvGTH$DE%B|Hg~N5*Ar30L$65jZ#_p&@P^-PoQbDWlRm04j5ZZ04k%HM2XQ{deW(+!d9f(3jZ2p69MYbrToo6r?|d(f?)v^c)|oM0Qj;XX*$tDac)#uN>dxVCzpM-| z*14(aN6F|VBY{ZmLRr&g_G$KI<~EQ-R`dV={XaMXGraV_V2#HcnO^6Lf5ioy(SMd9 z20os?rENaKuOY{pA(yQhyLIKMDd|{9jgEgN8vL}9^CIzLZC*6*@?|{T)~fBm0nIHo zz@%c~SN<|6mO{)>R?{k)1rR>ojaqKJ|K$_VtIw(~WzakhW1A6DOdL#E2S37)&f!dyG$m6|aOaSO`&J}>6b;-XMfYNBfRtO+`jT3EJOnx+O#czaB z(19m>?H)gX3fsnR^ngYb5VGt3o|t&$!HqdNOc9{Cu`Yc+z-C*wwADgS93#^@20Ty#r=9s9VvviXgQA!6|b8 z-^Jc79W<4k9Hu5fsF8RrLky5D=I%wiA95H~U`{y32Q{!snn`L>@8MwFTjk5-e!)o! zpbaV)-Y%Qv;Y0GHF5Lkb(E9OXL8}*&3#~`z5#`k4=gHb#ck%YEK05H_=RY5!eqR z8#V5*K<=og$R7m$!suxb3X&`_rXW8SN1?KLkp}EkL#?lDg8~zwLZq^m7VKa@N}f@x zL+=P~XVZ9N?AdRmZDdwEsP6niT4c(pXBUufMZS zUYVhnM-qztlGLy&Ll~&0Vpb>;6|mU`DTo_k(do3CZ`(pEpi4o%KTx5pg8vxGQN;%V zho5z?FJ&AHVFV%gWymcV88Fid@fILezFjW~IiLpp33#trgslo7 zDU?&G*!sMGtIMNL!RSi?NQ@rJD}P1^!!*XNBzuJ~zSHfqr@6e)^!Xv$=P?vsTR#Tw zv#pA$mX%2r_bB>TudSDK{2>ocv@_jln$8z)pam_a%fEX|9pL}I&+*^)^XKyHtpCMt z^JGjyJqs}8jI5}y)8oH0tPpzf@|=tufb17FemUf{9(RbGJYL2(`51A13N9_EKxCQdxoZ5& zS~kfQJHvu(Ob(dz1Ky`F_^P8}+U<*VWcmw6kpsYEEYQXTw;nw$B-M&e)o-@{tJl7_ z#2EQ2-b9U~qa*wj%_JRa;h34~9ar?-IIHVh@2OVFuR>t;&(fP=srNLjuYO@@kj~@a`4mJs;lYWovYFi$gImGN z1Drd)R^tOoz@;qc#5gBzlqSQN6uNAs2V7j2bisY{DB25H3w=kD>(6a3`;xTer^oM! zr8^eVpJ9YtPO$fKwzmknu)hTGs)ih%iqFGiBYqeGa*Y@DS`J$8d7k4~ijF~XLUK(?X)nC9&GS-VHYw421JSvbGCa~q!9c1A{?8)%# zs@DS!fP(W7FQNS_ciWGRu+*Zd^DwAHwoIR16cz+uF>bln*n%J|r zDO08|+H$I_30DWRkli=NV}-$Vr>n`|OUu|r3dQ931B@y5zR==IGo4g|ez8`0VLX7o zug3B&ti3zk-P>u6!boiz~5wIDPJe zH&et_3K>DSqix6||R@@6b61U`UKwObmWU3ZYeKfuo7nFP$6 zfjmuc2m|nj8`A-Hwm`G$W|w%hfPhke&TB4Il&2G*&b){L^VTx_U32SxmbsJ#GyHVT z1XWfA>fd6N#W!GNHpGzn9KM;oa}&c+S$yIuH7euP_hV&qFGi?u6l#D8(R~)RBuc^j4wa<*HAzKwq-{C5 zV@J&}Fq9E-c7<$8jJa6w@iFR=bh6Hr*F4kW-;6U$Xz;}7<~lFS2Z3}Lq!3q=O(Ti&{G?^Zy&mbwhn@0V z;rfK0Ks*&{%K-`IiK0dRsNKg0p6CQOuMXyU9e{V`5oVb|e6QBHo_RLIwR_S=j|z2| z5?3LykV+tc?x1gNr^%&Dqzw;Mrv;uzd26y)M>v>HmwwCq-HUeUE${3QfA!Uv0OPqQokw6{?M=8zS zB)K-sq%AY=6@wbKSx3XAW=ThnOm_z?JYV9(l)l@&v|kJa4dpNUrfn>2ByJq$1^$CD z!T7KZX)HljvYrk>&2iF3jweTU(F-pBF7a-K{K$hUZsC(d=+PkL zm`DQiRy;uLKKL`w51d;yfbt0!tK%^0N4kSeKw^#+^}PBN9puR<^YMFUYsmCtF$JXC zT$wAOuSAqED84wFqhd55e z9A?^FP5LeY&_EpUY!N%1c8ry+^}hIIrJf4nX5B>1CO$06;f_kSk~Eh4$wckH%e@vV ziy5ov@os^=o+1H!;-4O5&aEB7O`VLMADwhgrPjI&rdk*peD97%{J{B@avJ;?Ay@?& ztj~2wVmwlG<1ShHhqwen%(^TX#)u+qNwJ(oUb;=B$N77~uhUjE5`$a&6MN0`U)W7s z1Aaz**>#lUr%s0+$yaX#RW*p<8r?kga@*)^|8~YnMV|p(O8jt*6VQs2>()C^BM@Qr zX(M}vNDG3;_gf-K$$6sp&g}tD1KZei>o)|J=89)7ARWZf%2KsenwazgiezvL>v#FG znq@Eu@^vA|jMDN$7sGB!?p$@m^QKxGPQ;WkR`aNgp&a{c-(Wf1AB_Pl#bo10bufc9 zyd8OVzSF1uhPcG7BhZR;T)O~VI;5B}F}Q&9K*%&M<9404RqO@@T5#K|sLY3dMdnxI$M+B0 z52%;Tx^4Ty>~`dp&q+1ybhniu&R>i?Yk_%eBWPG;l-Divp)U-%)ldjiqVh=fX2DO07e*{sc!2_ODlaF!nzid)a%frgo+IXoySc7d=H_1*CP3vh7YwL3)|tCx>tif+Ca}xcy6f04C^-OlRjD{ zhK{}N>|BPBxc4X+9_=!jj^#tY|6r^OEPIhUbD4x-1N0K1)zEQoyCa0b{#Xje&PCNq(T`xa1#FAU3+Fd) zjq*y151b_5X8|N8LUI>m!eE&*JebVezRXP1Kj|ou+Ckeol$Hq-GO(BElov%mhuGFZ z4@svvuMTDG4!+nsDpsepYC-O+z-=a}W%||TeRweNgUTBnqS0K&Y#Sjv`i?x^DG;%IasFt~7n7_j93k>pLMlmn6 zhr&-8-Ff9Y#jdoX&!3%r1nMU&wh5T&iaK~Jt#r|jG5M_nUxDeA*^C_;T$mRd($;Vop6hmZgun;177LQE6Z?Ba zsHya-Hvkyulqz1nsDj$wn!Kfk2_*jV$D%Iywa;gW? zr(hNTkw$$DfXWR)5N>tLe4avkG)TN$q2K7{-V7D?e*?a{kb%xRAjh37|K6-Zc&``; z;<&Ll(p|d15*W_SN*>;n7v0)cT|tQDsdIGc_nU<6P|Vwr*Kbf6*fcJuYGz*>58sz& z?Cy_QzNd_f^1n1c(;0opN+;y_Hb59NGX|DeVssdSg7JA(?>Pdf<66!3$Hdw}&NoUg zMRC14Io|w&ST9}hKP2~jvTgr={$d<)5k^PIs%*;CfwKT6?1< z@A#6iRY=lXD#7BdmTFl8Lzs8j#-6J7ezQ4XEF8|49W3@csn{t0;Ceg-tC2%=P5WJc z=@ZKLZZvP#*kvohGR}+Kmj_5alD)U4pItTd3I9h%=t;$1c1zU`n#C2D5tOp??C^Is z*zRZ3T!Ps@6hbUOJj%t-o$RuguWnO2*a>phzf8469kd8`q?29LPpt-z%UFL*+Rrl8 zVg-qn($E-Ihx^y}hOIy5U(#+ghZv#(3IE|?Yq#ZdFIX+7nMgWdh_9q|y1vFObMs}J z6y|pCfJWN@$j@T<#tTqS{><-WaMgB=L_y3q(YmC zc=n>-bQc`mck@U~SKIEh{xvnAj;k@7cv<9}|1Xn2bkVE*30-FYlj+Z(l-(cQHY$P=~iv z#|zo%(+07T{FxA%jd$zF0&6)}QA`WVH)+(wME-RnDSW@52JgIuScMm-?v_^N&tO0t z-~4_mkwr6mjso(q>WS|{zMH(L0-^ibJaeycrYCywi{c5Y{x@#_e!k5_k(epQlTu%Rs;=HPCcJ?j1PI=Xf)SK{td- z2?88Zlz_xA6%BFa_m@DB4^%_E+CDaTTB)w8j$TEfXnPx-<<3a}6-p*~CqYD1zGHWLpYiiCP znYrx(|8}-IoRtXi0bK2Ung(nQe_~V5?`53lb#C8QO;j_7XGzBeh-Q<*V2TUnvKvin zD4a435(g+T4!=}|P-X|?Vh$n^?T)MCjr$|5^H|T-<6o9C;rS*&?)%g0neZ4+Od}^= zg-~dOy)jKM<_ud7H?3T)>ugkM;gfSN!hg);h*Za-8-rNAX4gZ6Hr%k;;S0ODpa3ov zxf%cZ?Pw)ino8CYUg*zAr&DAOirZ2msK#9wY13qxt#dd;r~~znInE_FYu))tS@FJp zSdxrQ!s7cTKW)s<1cDV>Cf`)X>Q&oXtYpAF%?0>ysi0!fJ$9&UTH_<3SPY5vG_GJkAOcm^UFOn-n zR!A?bA`#t!&p;PVEYTRo8A=07WMaXFWqQBCoGzo8J7$DsVNityi(#3AiC@Lczj|0A zoa!AA#Rr6|{_&vR<0l$4_B#lzFJ1P!)CO{Kv*Rkhm0h7ImU-z4^p{A4iWjrt;gvmO zaRjU}p^_`$=*70hO;cR+1KF5NE}_LtLU~#mRBC+g{7IKH8u6X#7iqdNCjVu)2C3V( zQWVT<31e3(;O(%tkertKbWBh04V5h7Qi4$GB@~osPQRkRcARbe$7fVf#sWH5?#gx^ zR;c~xXIFC5!*uY(*tZnRRG}+G&}Jn@M!r`rat|K3@SZY$)Rgx<$a(6$<@1uBM!wR; zZ*>)VI4gG!f~wA?LvB%xdSZYah2mNR2~1t>sKYY1(&G2DIMyE&Jq#Mx3+m zPe2}rARIfoUJss$K=bz_3$%i8oZte;la!BJe;aE)eNb@I{bZz z?SDKS5GbBM)iP*P_=<35$@Tg#Y2{mJilB< zaFnOx3xN*W2ghio0I|>d<~$Zp6_yBDiLKE?+Srcd@qC!g>8fNNQbV6gRI=C5P9n;O z%u4ES%d*?Q@|z7=0qmj@j?-7g;UzM;P*(|maY?*_V14#wF(*_th@viokP|lzVJ&4C zCfFno(LH@v;BkCO_`C1RQQWzg>j63{yC^YFCLy>!>yPpwlnBOo`HGPN~K{Q@MD{(kef(X0`DDUwIokTl|S%fpzybod3hf$MOG zQD^w<=_Xe30oq(s?mv$`d*;xO5>wcJkYTV*P~Oghbo5b7;rnA^ol+b%ktj+VO0+>O z*M~Z{%tYr~dWI&Rk%f2ms7U+`ey~WZVxIXYf;na$v^V_Exm;Z{I(=6rmv!=vf$_bx zN;xm=jVf5SRg!u8ex-Y{^ZCQTi<^UGdHyC|EyMf4+aS{nFM$g4jn`Zfjr@_N3++34*=XJ4iAIV?F(am+KzD zc}ZKdDBenTth9KfopFpZ28jmB{lgTVYs9ePVMx-wWDT3H$Jvzd!g75IoWv+b!=(z^ zjdH;AVdc(9U`tx+iT!14Ln@RYZNPM=6O0Mx){USNjNEGgYxI;`20;)-;ZBwnYonH5 z=}yC5Zpb|>5TYlluKIP6jlF%B^*RgF#(Mu2gGYarP@)g0x`D-4MDT>)95CPGI4p z@Oyqq7~;?GijMCe$?b-l7cvqkeM7*d;l$j>s~Wa=A{y9+f6}L4dSeIe$6bX`o>TL$ zgFIQs1>j;&J6a9c?QSO=L@D6tz+J@2@AVy-cATKTSls&`1D!Y;KBHmvxH;DFU!QEU zEi(qGZH5@T$u6Df9}{#mHBV*bhcxPRfJ*#eLnHm=w;@DVnJ-O2yen6Rw*f&c$q|kIC6g+;x*0gD8fKV za=Jh)Qjmcj@&UlghSeRQ0nb&DLGz~$7{i3(gKJV0gM%y-IPYX5@33zUI*58`#h=UF zygO{WSy}f(|D~F^q2h%0NszbfaHr~x`e)109Hg3YlPJ*Fvskf?PSZi12=HzLgS5f* zzdpiKJ)JYkTS|pv@@9Vi^zX1lc^6})PZAhFXbI8@>4ax6FKI|`4|?BUz%&CMqlMiA3{!}2=@M?UeGc8e5Yyc& zU-%=I^WvXuitO)Xd-gJe=I7NUmR|4O;dl$HZ9QXlIQmT;1|VPp;laP$)HylsU$6Sp z0p>!SYXnZhc(@)ln18-66Mnwv=C(`Lqz(cyI__|UFG>bjPvITjigr@f3N8UKFMA7; z?mmT4o@drH=-(!@R`wIgzaPvshjh?>0}0bhs(k9uUjYacHmrDd$>sp+vWj ze3+f7R9(A_`|XFuO>n0P2{>-2lJx-cW2#67bQ*O60L%^*3?BY_==M2)OP&EW>%PfdDMZ6pQ=*o zS3{|M0?1QoNZ^he8B%i7P>Ap#sB8PQ>*ALVPV%@y#sa5!z15H~SA2HDh}CvgD<%`2 zLadGkj$C|(R1jyX3ZE7!D}ncHc&lw!|H>!|?q`Pv->q58)=KtG)s3{X3#*TsnHLOFAkiq6FE0;f3gb(lJjF zY-73#I~(MXYdP$bK3F1o|ND4p8yHD}VPGEH0qwTQU2t5M!7sp1q3t=sb7K@Vyr`Q{ zCk*o=kHlWU7_eE`uvTfKxUE!5K2r1%7ePz_2&7PHqmH!mGo_SZfD(-g!MgVmOqT* zr_;8rH#1qGMb#NA%8Q6>b{6vU8rSCzGAV?sG{?tF3%E2$`BD$? z;{GDQc7SZH-VA@?B?NNXt#hV36pc`~go{*U<|k>&j0Ti_Iod?Cac_L8qZS*@KqPOVfyMIpQ4h^dR{9yz2Vc8iE2OPU>#{J7Gm zs{CpUnpIb9uZg$PmkSu*RXk)0d^`G7&c9T6td38JvZhFFv|{l&W&D-mdE}fF7h=EM zd=)E#(8`N0 zC8;+dAi+PQOi-}1U`v1H{`;t}y7_PkJX8|S2A_2(pdv%^vpUzXh_=CT#1r8!nJGgo z&!`^WpJEqqz9#C}{x#BmU*dC#0~pR(rB$O6t+vqQ%a*v8U9-mnmmi0cp%wasRE25? zOF{HEN69sx=I#vAeUK1ER#uSp_^p|<*FwQZP?)Af@76^$nP=yZv>g{SPC^2!fa7fo z>l(HTbtcC*OPAtFkG(u%PZ5%SRV0Km4d^vE+YSz10iG)pNEH8(nQB?zHY~(9sV}E# z+SUe)VWj$RWd-79T=hQq>V6Q(RvoenSi$8j`sQVrxMIkRgr~zw8p8^bc6Y3~{8(0!lsEOQ07((BclowHA}p;$9f63%#wc zz%f{CtT+3?)>w}cL!y~uF@zy=^noizIao#r_CT_(oXTV6i>IBA0d3|}n^BVEASMn>|+ zBT07Sza`l7eQEv>Jk+{RQ_d5;{47YDbzle^H;rIu(|WAj4Pva@pPJv0^e@MH7c*?HP!kgN-${b_G-KA=1$=kS^n3WSt5Tqyr2TD z6iXC|I|?>(0o@LfeEtXW&!@^lfW?kK*Q==29{Fn-+D%TS`>`T6*T1x@JDzOGN$;psdZ4EiP{VxAPT(<~H#$H?xxCxhl{pbwu%S%sR zbbHf7%EO7BzQidOEE}4=z%!0v9Hn-2oxEPnnikUaa1qqF$}%P8Qewf+KEm_Y%QkWH z)CrKu#fo=g#jMi@BHYI96w4NQYEGB^d&}Ac|D?RWleAOMs$J+Pdm&S9lRTmo`QbEp zleJPLZ0EXjY059`LN4Q2PN%g!C(Ugu3~Mx-1M$USiysT-;QXR841S_e6>stth<;oj3>?LK!$Vh?Bn zW(;El2E9RXi5DYeu$?E|nW|jDrYmw}#oza2xH|5WgUWv4&(2qqa{{Xwk1jcPK3H%r z;dY|5&~K2N@TiA7_H}y_XN&9PjlxIKt^VLn2n|JKe?CnaCN6hw3)M?!a#E^}{KzP* z{>-AA^nHK5?02_2RM>!{k}&_Ar|@VNRe4chYQv6-$O(laUOk15fHP3agPT-C9_Hof z+o=}S7ZD%ycbC1_;$^e@$S`+&u=w^>Yhr|TWB^)NLSe8HKXmF@4_?pG5Ei-Ik{s2L zx0Mj5>EJL8=X>39O4{21^oOEAzNg|GY3tc>R*Jn6P$@bUa`EDwbb0U3&Lp}J&{dU- zGYUx1xB-Y+S$E@_ZjTtfrgk86f)7J#mm4rQZ+u^o&)zBkt2ok}f-T9CAdhsRZI20t@q6Hq1qHMMQi&<`n zHJZC4L!@A6jDt`-+D1%~8@(|#J>j)-pr+|GLD}S(DApsq8a8(Bw4?Trj&z3aERO8n z^}dDFeH{^=MR=31oDz3`e%dM;Wz!&NMVZ1RE$d*|Pv-47Z95}^w9ajNp|iUyUH8TLj1 zQyOu^D!Bo1BE}Cw2!~r3s05A!?uPOZs*sNb!l@za6*-##IG9OzM}Zb-ApyJrP?N;n zK|&cRi!|v1`^3WE0eInC0KOPlA9h}n%$7foHhdlM>bay1djbv+1^fLKi6Hjy1}Ht| zX;K%f)zfw%&B#<-cz!>A&uqk03=ZDzpr0#G-kebTsUq!Zq14GeByByySh{bxNCBb} zkE3M#3(&Um(rtJb)ye0IZ_fN%>&mtd;MDU>cQ_y-YGEaOb@rkIi*G4?d^QB@3~TwR zc$3jan*?y!`VW8E8)SoNv3nI#670>URwUMf%wRs!M)NWT%|xPKK~0juI&1Nma?}HX z=w|?U`}??Ms!lqr+t|}hBk6CbBk4`xW`d;1Ba4)THpJ3c&>X&De6Ii@SK_j*xSeeB6pXBj8k8R>pQ{FY% zLKYsQ@M|G9>R%?YyfR0f0j*zh+3HlJ{0QQ26z%b0ft@E_tOxf&4efd_y$=iIk*-Vt zL7)Jp2Bjqc0k{~G7Cs8fSsoBpdsPVw*xrBu@(;HZmPAk{0HiRpI${5Wq%&n$Ce|1Q zRP+n{jp&o{jPK#AA_IXH5t0vF8*T3vM2=d&LrS9ksxz>XmZnCSX@4s*cD=n!vzsovJz0e=0g*8{T8odn#@jp^UrNTwKQzTO@ihOLZw3#~h zahw*Dq}$u@Ee;R{vn`EWwP$*W(wOSM?27Br5Hueud(9Seg1%71YIwtr%||!}?vb)` zRTuc~mHNR)cP_sXapKF>wN7<+sm%q4b-q{UoyGzLm2kG!{hvLqp#EqV59J?ZZw zl&Y)gADHS>gigpIfM%4>i1#ntmhWJuxpw2FT&D3_5veHZJNsGZ4@^hfnSU=l@w5JZ z5<`bb@~tz2BQ8SFkYAm)ws0BU?yyp4g?Sc*R0G8M^A(iq z=H@?@9(oGC)+a6t==EvOOWG+SKmJAaKFz^-bRHf>mVA1La59v@K5&c`p+Ec7w=}{Z zobCItD)*wmp%1v1r^Xoz+%S1Vu;71q^$ToDH^T zhH$J&=4f@Pd(rC=yn9b@=)gc1k{46HT?p!f=$QGcXuwm~`l2q-YQT zf53!@Ku#DLuEXRHQndGIZk5lmW!`W;l{`^U-p7^K68pZ8+)p23e_L$nXJ@*LVrI{3 z+}hw@w0T!?(Syt=Z)jB^nLY~S^(4w$>;IXMAlTg~^HS03T(L(*sSV)f2t^RRp|$m3 zh5kSZtT+BrT3tf$FC6#0MwlH!K?@h>UKsaO7Lguf1Fsu%QY7VD3bUS*=G9{Hm0tBe zTUBfr#0XT0Bu5Wopw3Lt2i+owuScs+{n{SQq~ARmK6g6uQw6l4b-~7dqxc!UuA^M*Uhc~(Z1abC}f;?29hnd3^ zOH_7&v>LE^b{nu%>)1>p7u}EpJknQiTJ&_3bLSU{@5c*QkYik z>a#dEb^MB;Jes&&V~l#sP7|e03DWWoj^Kh0f^TEJ%aUIoy>U$_yN~yrZ!*B|OyXVJ zeyKo=cF$|Q6yG2cv@v(?mC;9g5=P#>d*~_j@{DOF(_6vjPw7Xu<-?f<+Bo@cULL%# zr{LQ}nfqa|kJBVFSKUX!3Gbzrdk#l9U*Oj`!Ny8}f>`m05V(b1vgT(aX^LbdFO(#! zIRZNUG}z6T{)4xQDTDnG7VO;0?cZ%-InjDqByl?gptQzGtIPFMll-zUG4rmFvC;Kz z43Pp4b1EI~Z+PqH)wlNb=-gp#I*Gnw%^Tj5Cf?h%0&t){sMf($8o5iBZQD(CLg_TO ze4d6{b9Uo@abzti*)Mzf0rfR5IoR#7nu&cyCv9hHoJ!eo zukN{Unid!jB8R$nkM5moBA0Dl5BfljuKPy_A$V8KO_~Yqk=%6HZ(hhm!-05VQ-Lir5 z{2@D{ZhMr-C6d)eHduK=hH6Uhe9qqEUWbp1MkY{7pj5H12HxD%`b6fwAE3>P$OJRY zlC;nSBfwG59ZaFFbYA2r2eCoy&v*|jSvFKIxv?Rt4-Q>Ugcg|*Sd;JfQ`c^};1k#< z@SY7!u8Z(x_!>04{^6tSb=fT1QOldJB|bA=obl7MQ?Imv0r;R8tDY&Gh&(`RsK$}m zwo7-cUTJ=lsMGI`y>=13{vl|TY^(=sO?H@pFC=_`AKTyMgeJg7S48wkdS!=dEaxaN zHFsY0VQ3`Pfdk9kI#PH^rD^ynyQ&{cC)ZVeGuNX%lgA4r0Wbk@VvLY3?+acRlyd4HOsN7AX-tHinKV~d z5^f6(dmc($AA&tCcbP#^E>6{bO^scrPa!BQ5qG^8WYFT-)BYKydiSjV>R$E166|{s z=5{I^7#uJo5A@Feh(l=>Fc|NruDi`5ok`DtH2zoEC4qC>OAcu3w^OkHMW547plGpc zvD^jFopI6K2yQ(ojQDYjrG@TVG(DfhfQ5;2Iw0>I77v^~K>j;qZm|hh}xv zDsOhltt3fxdHtbtZHTXw;+~k=QJfF0U)k$tKNQ=mu}Bel9v4iBe&&+Q0l62%y3j2S|*7oz@F)!kwJ~F*GoHjqAc1|GInNJ&eiG+UCtz{oY zn5p^7vFoVmDIjDW))LY!M}uHR%oQe_p(_U7aa3hbXs*+RLq%CS1l5D9`kS(+%Cu3k z(K^pJ((at&w|<51TqDU%XO>@uP;HNJdvC?-H;2Pcz0Kp`e~^aWHl7faQTYSe!vFww z=AoM0U)C^Q@?H!-QO-n4U3dQPH>#88=3^p6Ch@}!wfY1Z=40OmH`k)5E+0_i0UFMu zUymN36HC0)PG^!`&Wc1^iLVKeRKDK%RRXU`{$V0pP8}AnL)?~dhnA`wOj%|omOQ>- z9H1fc~F8fex_np6RCeanJxJeQByu)cN`{Ea0~EJ?YIU*426|WY~Q5 zjm(&m2^nn2JNB!>h73lgLN2L4^=MGzZwv;6Lo81i<1+%D5+6VwAwf9c0w@fKGa?Ai zG7O@=AjkJ}knsy6rVA}Hz?0wb6b|*(?2##q`%Y_qnATqNi@bguWTzg_JN##X_H7H- z!hL-ITU26QiU+b`3x^Nhx1zG7V#Q_P>y@!-5>#Qg{)oY_;mta2L%wu{ZFqt1Yxr}+ zZ}axsh)CSFw`j*!iznNa_B%_ZwF?~wx@SGV=!V75=IL{fL4GxJq3=dx3aNfq=UUTW z-I5s9E5Iidj|3QA$wI#YgehXzDX95nj$qJ817Bt2w?TYac3-tsCkBhNc7f}MFaRrv z4rCw$jmNOyR-h$Xkp$g+gnL11esQPV!|%55=_B?UtiSzkqmwOGgOsUpd6-2GSz(rj z5@(o^+sVgbCT_px^wqe1m_fDwlfOasHADXZ?4h-ivbhd^I#{=uu}q(ARwR#HFq&-d zmQZsDy%$m$`rX@=m~yw*cPiHM^6|)1EJ5?l!1(r#Av8Z$IMYCCRVbJ64F%tyX5UeS zg+cF=4ZT)BbIoV%;9r)+*Pk`as6@7KUaf_>il$}$b!IXrQv61|3-1~*v{VjGp&?v> z+C$%Cbv6Ws636j%zinrQmk`jk3^mBVg_stHjuy*-c;|3#0?2_?zj=)=$ zqLEA(p8=|D<c;wtL-q2BM+)ux3G`s6}w_cj1 zM*6SF;At0C)wcdCf($XsRvM9t+-?3;XcCg_MXuRsE~pY+dX?Xv+68kEC;b&KV}*|! zSxQ9sN?^=TI|AU@b!GgBGe@G91Y$yJ(T_Di z8*^iiDG~!TxV91@OxN~fK^>#tx&|tNl?u}%SdK}s{3K{_pfh$pjT-1%0IPx$(V4@a z8NK-R8B-70_ItkVEQ-L*G#*(_1AiU(fk0@e1Dz_)Sd1ZCI@^KaGSO)Hv^fu1J%w|K zl)|g=gU2*_g5ML|$QOu3DfK%=hoQL!DRuKs&ssm1Wp*>G527XoaU#(C3 zXNNi`avY5ZZa!x2Z1PX;k+E~X#`Y-MTksRE9Muu3IYkG=qYpjVuo1(&G6CR~5QO%g zf8FjbD?~YGbd5htxAS#MSkhMy5^y^ZP!laDch6dInAV}+c1c3tko+Tezu7`zsm<8x zkT(okiE@lh2IusjtKho)0!M`sqa32`(1X@@3nQc_k@DREhBk&pF_&TA=egiY%ndTo zp`(x%B;C2*eVxmr%|u|tea4xO{a?nb^*vImn{@fJ4T{$n#E>7pg*T819zKuBcE!Un{DGmn5IaiT(3>NIm zi}K^}HP@8xMJdzkKHM9(1F#%8bP^$v%~o+y&B|uLl=Z8*M?Xj%EOm6_;-2&mn)IWWJ0Q! zqz5m_P-2ywPXBSnGS~6Bw;5PLj0o+4Yd@=8pwj?-wP75m|N2RHZ)g;zO~>rgn!TUhm7Q>yhXL9bi`*^1ed0BZ-2-Drlqo)zcVkBaEJK=&8m&gsQ12j7PSPQ--q&Hh~T{Y5Wvqzwtt?< z?77j3n2f+^c)!YLEG;d-MkQ0HHN1hG;gKQsX>LHbp+KKFY0Y*&1~PsHwzuxl<|Oef zp!;C{&8C8hj9ihC5g$!>cwd4)xOQw>RT!(z(D-*9KJK|Ih^eJFkp&)zq8=&OK^p}3fjMN0|~y0r;NqGAtpW-5UR``CfBOnA9PyVvl!Em{0yuUB}fG@#!s<$-+8h%4uSnWe` zJyZ@G{ja?eWgW@vLk2@w`xKujU$fmP2a_<<+(yFmL@MaNaDFF)YDrPyDCAQm?e=^E zPsQ&Ngu`Mx9t(Y#Vg2$mEuj%|G4v_E83Gg&5DOq~HRKmuSQeS*3{yQ|8eUx}?;hYG zysZ6JT*DiHM^k>Vw!S?#v0Kdm`}_!J%NU?u(6@=6~guyM-7~aGtUu*yU^)46;7;*!>kmH zfruAmxiD|p^+fBC1-cd?IpQ$mL~8ScT6CAPwV76NL17#lx8_r8F&ApuD;{E8sKfEA zL?30cz?)DxG72uk%N|OhF8kMwp&3X}iHq2l8g97S)Eo ziN}2lbfpc^BI!){=&F~QDoucZc0&d(p&L7r;=Wm4C#zCGh}WImFgYCI(xL}kAs zzWp4g#q-tWSM85f!VKbXzn8y|x&yENpx)>m+)frhG4!t7h|F+~3Hf62E{TYkjwPB| zd^+D?m8!8E|2}1N7f!Id^Y+h5ZC=-wg()Kc`Wo$k8MNCC4o{R6`=r;b>|vhqlLAnR zKEF;DyR;Jp`H`49Kyh{*vOc}ge9mfYP0${q=#NX}m&NUh zIcbwylx{s-c4;o6tGNDxc?V?f6ATx4;R>vIcKVZ*T@thBp}E9tGM;V$N*-qzr=-?V zAkJ{#T(+%pnm+$e{*w3gr`3bzjjt+y*2C`c8C&y#1G_kqu0(I*-`s4iJ7LDz&zYll z7;wojsq(c0FGgCvOfNj3S>v*GWHWK(;&f!7q1Plmx_#|!_vQM`$NrxOn1tvp;5To% z3YhqH6u4Xc*nM@7@U!QhGUcr98A~p=pQ7#_00!6#6fv*by{tZyG>e7o@oMN#<-p$J z(Iik^LLa|(X~Jxdm}1+I`?KbM$6H)BSG6Y0sOu{;epHVA7>W%Nv=oJq_sAHKUh?;z zQq3MLRVrUEofu%xww- zb|LGP@=+Y##qBHFaZkv!`oJn0EHf3YgQ{^cVo6EH-$GM>B@DkKOJ(ZOW+s+D@Z7Yt zw)Ih;XL0OuqfRGFP9F2Vd$K3Ia}9UA#8j-JZ4KCwHa;frcjtV+oHV~~bJ6(|I0D!T zRZa+0g{ZcA9$^S|J%(e$9=v~Q8z`#S3^n=F8T+^VKz2Oezf(hIs+(vFBMYQqbH7@g zTsg^;I9^GlTHT>755^=$XK`~MkMOm6f4_#zJA`S5!2dA@EzqN$4%|hwL}uRF4ihfm zDr{KeqjN^yC1y4UAp1LBamPp!H>JHiLRZvHOxNfC{h~S=U#JCK@qp9u{<#>r-UjDg z)dyL02$Ea}*Sk>D+!@aM8BDF^ptS%OxDmjAj6t-jtwVg*g+!&zpHc-G$aj163u|qT zw#<9X;s5=xo-S~8$?OCgf3$fJXK?r=B430Su!Z4Ihs!om3|R_X=rf+sM=nGnb?o=| zB@JsWtas)LyXHAQ5M90cmMkT_?G23*%zBYph#S_$OsozXVID*DqkHeb1)$bB?QZsK zX1K8e$E+F=fVm-JvIg)$p#S^y2JXA?Xy2&cS4+1<8}|9KLq3n`#Kj2Oa&{wdHTPkQ ziH$m~E;{A$ntw`owjQyg9m|z^(L@g07_km##A39Nv;u#T;tVdvZE@uj|0+sf$HJ7y=)8*`RxTt#7HUNsd_MzN41{y}@tAcf-D=C-H9F8b=pU*@Ogy_0 zPvs?16!!DmG7-keCk)6y!B_1Z`8N{i{fgW2fzIBXgC%mnpTQP?vwY{ev(Wf<^ch=# z=BsXtsT^W+-rc_sVov?iK4kt{dx6uyzO<_rM37^n&}hsIBC6C&jG^dOPia*^@GC4p zG73Q467)T07XN?u~R<6CkEy!n2iG=kmuY?)@cr(;ZI5lO~dkp5zg4qYE`W(g>&`Q{{E6dIMq^znqWZ|c&G zGeU>~*f}FOXUftW$#NAp<%WNtiPz_Z68A=Qq1Zl1S}~+zC){H{zCB#;&5Kz!kgWO+8$s-`PR>ti1n9nnuKoD z4Td~A_r*3LD9%<9k}6UFTz@%*L)k%7u$t4Q$%X4^wi9){r+i3sS4P2G1d~MS(u7R)Q&m3o58quLnyEIYvr3aHr6Q`Ip`RHNuQ{3v_1kh%y((24{*+umsTXg&LpHHZ>5 zQ+TuV4;%DT&N#4DpxNt~zZO3@E>5?w_6w`ll^8ghAA7cOYVug@^{L$$x8dy&>5y;P zf~4!5LBi@V795Q;Oe7|0;?{(+qi*(4pSPJRe$58rllRGADL#rBB#LuE486&grTb?} z4de%@6rdeYbK^mI*3kCmYcZ4rB1=760)pZw1Egg>*22b-myh_#GaP)kxl~WvEfAE6 z!7HJE_Ab0IbmF79p85I)Er$W57^3M@FB!ebKvmYy5H#HQ*b%%Ox7Wilt!fC<9mtqw ztVCS6PG|^!aLZNR9>k5>I_+l#BjzQob+oRW?97pkqA_C|dXWv^aFY#JCh~dnf$@UZ z4r@9F=ZMlYg#NtwxqX4iU+*$p7=bD}JNA4wriuFJFGS?svrRmQq}}ZN7c1V*he<(G z=ItXg6V>dpu%j}J?cdP{?6q93ama% zr*@l^4emWyfZz<)gX4&dg-)xLu^j8wzR*wdfHt;%wzCaLHFLgS*z^w}f!cyEQnFec zx{f<){Z%q@mrRY6ySkW&ILXQ5Rpc5tYBv>IolNQ<`_)(X9NUo&mxBd3_}h39wUngK zAn{>q&q@+fp(Kyu6Xs>PQg&#Mp2`!E%cNo&``makklPIQgEDcsYYaP_OvcY!N{h?* zBP-jVP%yTB`Br*{o(A-gS)j4_$-dgtc2oBDn7K+(qNsu<(+o&}P20Bcedq2X^9i)h z;=>wtZRX{ec_VOEttRX>*%GFC5Jo_qxZ$unwud|2eG)^K$4pz6q+E5LgW0+A?X7HD z`)d37ri}++qR1FnUsm{+FD@9d5lgiS1_gZxb&83>1*gS(XkqO8?0CZ8|3xtn?Y|71 zw11v*yi+(%B>ADf)!yD-+DsUKsRTAQ7A;Im zs9)fCiwC6RM5auI?qPa>zExGvjPBZ`#Idv6VCLH=`Fm~V+JWa*IRH!14a$wvD%kAO z7cymGFS5=F*z7V#c#NAMx^+iPTqyuI>P)0BIA0vM=^vfs?yF84@eK3oT1N|ZlA&N@}79H{M-;vk> zdhx@B;Kj)Lf+naT`{e+Iug5*Mh6WEUE)eKaU1R!9T0XeRF}{B#p`+P{!l>ZgOp_nI zzx>RKZdN+eZ#J%z>an=(Ip)P;79lvo74A`6CqVe$rMdI^9yRWI^10spm7w0s5~MG- z0znpcZ1t;A_+AT{#*iZ0#fG>yo;vX+dgO20*MFguY#`j-4{QC>IOH}>#e~Bdi;||h z;f?sga0JO`=dl-XGHe0Cg%@pJ22NV zgCLQeJ%zx7lktq%X*q6?7qa+U-R4=#pmM1qy!&y#a99caEHrNQPLw(* zGyW|36$zjplARn#g$1uPL(UwJdUym%OW>fU^xo{tFMO1ftK*od%?!#GPoui}jSjCa zzv6%e5?f$UAMq7&3afgq9Xaj$0obZxQ2AMK=u%hGO-w}YhN=<>m&$7ZDAO$6Xyin~>flMhe?1o_x%zH=(QNR)4T~WpDdnLS!3@x&Z zfnQ*vKpR5z68$k~%U~VbY}_E|4AK(z68x`;P4Lumoe3Fn9}WqWLfh0yea;p>#)ZD~ zR^1a$)Is1F`rc1+p%9-+*E0nNNN^M}eD9GLar!xnmS-_#7r0MXZm9&jjf1VLS)smc z#-&;guK3N%Tpp)Lj|StLDCRd@ShV*~P0n$HM7Axzu{<;~ z*;D+v2(EvNBOET-0c#n3zG9JvDV~FouXsIPnXMu~vRBtry3`HCUs|a3xmn z*~X^yjtgO$gTc!G(r;So_?7T$3LcCXdRhX|oWPx&D(Lml%Eh74_V5t=B;Ew62r;mi z`e-)sMMYz3oq-WJ2oMzgjUwiVB^yGeHE7+A+iIzKj7+MwB{&?p6HS3i@=7)(q-6312<4Dr#mU-X7 z0~tzZf=xh6t~kcL>fhddq;*K4T=}X0|{0Ak zB@Ohed#k7tfz#f5 zs=pK3e|-a=ff`ZMJ9X58OdKrlV`)>6%#745@%7P@57>}fNM*6p@sa$3bqET)=XA6{ z%6h|2#yg0;7mDTc83vg}r{j*N+F3~gJb&f_MWNG*H2MzClQ7P+tZKuK^BT|gmT#rE zs|074(#MYe2y$@kh|m6rAV|Y76KF)H*{jB1g<1}yzJDuq9I5@wfERABq*!B%9C3Pr zP&(oncpPO8Z+m$}@wc7M+=J3^F8p62_BQ=wCZ@5>{cgI%+h;>T3ue4y6cz{@mO9JT zdJF45hP*FknnOc*A1g*`vI5kqO81R`)@K{yOfj?U1|4Ig4?!SHnADx!Y+-$D=by*^ zQ-djEa_K7?nZr35q}7Rmf8XioCb{olE5@0lA1)X=W42Zvifiph0vxoY6Z)^`|0Odl zKW5RclWjO@HCQ6wJG~zB_muaygM?Iq&UXOA{1Eaj1!MBtRly^g;GO ztyOg?XNbk6_05OlFj>pO$#%8BKp}`f)&5b&?K4yM;U5N9GgvU$%*2p04<&VELSSDp z9x1hq&q@-64E^giH!F*A3g(;28x9CMh^2RBjpXsc?wF%|O}(GW z-BB4GqXARm=B?v3jh?~9B|wPzMkco^*!@kj{9ES)vuvyvxH9mn6WTeeQgI`32gMos z_hPJi-Q)3%-Nt@MytPJDYUJn<_buv7jF9NA!o{{UIf>n_WSXh$%BcCiu)g(nHEjgm z(^<~frD~sNa>R||U@5|ZbNq9A@kusDty_3dzet2Y+2Gi;ND4n0`@*qj^u z>V!m?vw=0J6GGm7W5aRJeBf*-CIu)L4fJ>41rQD%p zU3-pb-FogDrn{c&E-(F~jl+i-!p5UtihHN?PvpPdBSECXqEEMhd!p7?fYfGR&wA2>1yvU;verx7y$L7ubfsw-`{d={^1H zBCeN-F&HNRA!8M#w~qy&T)G^m*g;EnACJ)1FTs~~J0JQx|7z3zdd1F|BGOpZ-6Q|Q zqpXz2Pe>l61{(vpVv%JZU=(*$;IOe_?o{-J-Iju&??(Oi|8oqIeSS`E^PQUe9Q(4; z38=(55R`-0U8G#6uuwigMS%-~`UA*bS%*VQ0aA_pW!|7oD_0ogT$Mtq1yGaTsYtRNXhE;xwbylrl|o&M)JE^suAppX-9PO`s zO}qAR-aICD0X^>RFph3`jx4)scAR z;{ac1t0V}nG>)-GJx^|3Sy9%K!PS_7&~ndH8GBAL+NZyA9hTX}fj0hJ66MLF`7v|< z1UbWNz3`^xh6>yrZa~r`008qoiDWzJWcb4eKb099>6zTm%WVgTY^0z?%&fIWV{j0k zbg%>AwTJOZ_$?8OU!{TQ>d;~P{`ziEXL64_8Rp-EqEd(Zj~$uPcfK$DvXL`z*^KY; z(|x}F0HO^DMlzeg31hV5)$%BmPi*ZK^&>Epw)#i=`ULRS5SS3i4v4Z<1*Q8`g86f= z-JIWOZ{(_@AWO5KZ?RC+>1rB(_TZTAhfUX6+D~w0bQGLk-GkkL^f{!=Z>l%0Q?l^p zvN%x4!1*O)$3@H2L^x#_n=t%nOyM}@<(;4l$V?L!}%*yu2F4Gs>MCc%KrlA&| zn;`(uSC*}Ad(K>m!bjX9V8%5YW0A<$Km0UELRqtqhJ#r2@&u?P;vSAYuAECTUf2H>XA%SI z+~ef=+YAYMp4e;qlMxP3Qa=DAfS@yPkR4Ap?qcfgBz78RMW5fR36E2;&DF4Qo)0}(bOEy~e2wJ3S^4g?N_>tDEHe`5jHdB){c|Ru zrI1N9{BZN5e?#5lm#qCn$L2&_X&q^>{&3`;CxCxG%x|tQ(Z48u3y@vC8VB0cu4p zZ<+iN*DXPJY&>%Cqqr}i}RS}r5I-tz4 z2;3n9GDqg5H&T+iRdq|`IlU#P`hG}>zD7%xQ%VSYfRYiJ)&l5Ujvkc-S<{hVXJTFv zt8frc=8@!0$YaSh1=N>=+ttCt_RC9{&dV>Pv6We^vqiPTx&Wz9?Oi*}R#+FpmgN=e~*Ernq&7W4NLR($z7-mFPB1n{DQ{ zAqulsy}zX~D}5izbqZtJKqoEsHCD7t;49F3ZC{jxpy2XjH@T-qT#=Cpc7)-77sXHa zJ4Fgj|8d~dNGvAoy*><@-42PqPcN6kAb!+K+nPULP9+rpe}CDLU4c9C-KK03|GA5q zlY`NzPuGeKqrF5BK+pthbMc;w0gaGpEZjo$-o%+%>6;7-P*<3*EAhV+{i2SSXSssJ zEmZRpXTOIlHWq0=_v3~%u$7TpH4%b5HIREbKxY0x*u}=Y#|VuxUu`e+%7_!a1PhdzJuLvhbBWIbt;xsRSyZ1b%^k&=2V{wEk9;!|Z<9oK9vG!+KT| zvSCzqvS$Xe0QyyhI9?|=c|k@jQ}n#o+@G~x6zBL*y~|>yO!3v=#|Z9-Swj_Yqp%(t zDI1m3bByx6p06QtRfXjM$ zT^Dg}=X9!1o4hBDdQf~=B`5LRS#EG1&rP2ir`i6C%>48<4hw|nH+@ul{q05?qr%Pd zN#yP7&*P35j3E2T=WWW^&)9ZVw&~J}wkO1luO-Ms>*ZNM6t*4m|4l zcuZrCNi6+T5XO(BlD`f|ZAF+XReqoUMxq6VeC_2nxbFf~D^L&adbqu|=8u%Uc zomE&j%H`)Vwh-u2vocnf(19JM!)GQErr8XAU;Q07D5`P?h>z z%ePeEaI!>&-MkSmSIwj>kwHH!ehyTN8k^MfLzcmmy zpY!!8BNZ(@kA>0`rXbJf(>0>Ped7XAcy|sRJer;*#~mr%urZQ|#9P1znsW}+e}=;E z@Me#GHPUrNtkf02`K_V&<9G?1we*J)7%&K@xNimiC0S52GLKB%tkq_&%#!-bvFbG* ze$)*zB>O&D;()irX{UW2g9f0dM4!50*MAEeKe$DG2|@>n*7N!s;=3o&)1!VQuyKJ^ z0KX7gT6fK3G;p2;Xv=)>A0me7Vtq=$y2<+W0u)Gs_aO{V= z23+}v*dYARxZ%@>;4(h{;P0>QOj%~Gm9R`~ zIXTa%x*Ly&rg~+zDgbPB6f0HH0<)0NUUx9A*P$*m^S@>oH20#jlFR|@Zhkd}An8+o zN{?hEPU%UOKAtK195fN77gvMH&i_|^74b7j6IaLt(+yGgve4ak%izaT!dUr@W{k8Q zqe3P?)6ry`>b>}2IGx8%6%jD{n#z%QW3Ht6G8wb>ltfj)?@o-WJ6o zLf<)}j+EUpbcIDx`h&dB=C7&a9Ig7SY%muR)oHEF&q&SBIndP7v9TL60n* zt|T1RUPa>oBMw4;l1lLJ$(NvEu;8IZdI<}>^tOKo$SKk3neBu*psW=9edTOnk(So4 zA8mwj_uk;$&3x?R^geNl(x`{EO=axeeLS;#ReGiF>|$POlEFSrlmDiteM!BJAV>Xq z(=?gsHqI9lyuEvv3TJU2R@MgI?VycDl<4IS2h!^=wB#GB&#T&zNyA6Zfi?;N${m z{gFq$-I4giRKAN%t7}I0gP(~mp*ndE<0>*v^V`W{i<2t7H5Pd zNl6a0)(d35Mh*3e-KgSEQ5?i((QRDgszn!eu=x5dA{T1L{(qkhsl+B#XfO#sbw+qO z`tGcvW>CpFH97}aAUwR$OOh*E5q}VKY$Y)lqSj5QJW8U`%JI7|5^$0ap@|yX%*j7B zUy8WzGRWL@GP{XRp2qcJi@Fve%Q7V1dDl}uO)6cS7|MdzAYxud+qBbGmGCN2F+e6v zYK-+!Jw~^3?ptb!3)L$-Dr75&lw>z9iYry}HAx`dTme zKwx`Ut{nsE>P?W7JZoadw4J1~hr$l#ylT)>?TTSL*OmUb`* zFNiD%kLm2x9Xmb6({yKeQa^ZTQ>$A3~Z$4lX~q!PQbA!!WI{7ZN1|rDw^Bg_lkG&r{Yeoqu{%aS zrRWb_UU>cET5B6``%W*hywzC)*@`zY?W`-4qb{DR0pFla!7WvV7!Hvgm-R+;Q<;(N^ue3g=qxWH=hP0$>ZDcK0j4SCaJ>JoIH7{H$osU7R|Un>QXaEnbWQB z7-{-v73&{Tv4DI6#LU7`#2kei* zPwG2$H-}E!5E*jT!_##@Kjs-3oyw`kK=K&@$QJD=v#%3N|C;28bgy0_vQwz+{AvCk z&at^E{n3S*HoPtv z-o^5TljJa#T$PqxC56HsCY#CTZ})Ig17mX98L_SPGe-;+E5BIzV@y)qvys50Ecp-& z5!G);K$+Ps)50W>hv6=Zht2ijN$MM$ytqv_wbSJyl)%0l#1|BuO{so0W;XeU#-&>M zT{P&~tXKgevMV)Y!^KQYv68a8v1!Sty>@Lu7JOn7C*$iihU-xX zf~oe@jA;_kw~Ul#!86Ry7DCDn$cV!NUIFt1DS-FE0^X;tj?bI0a-jF4nS*MqS6hsE zj@heteFUg;KBpRL2$hQEX71vS%^Kdz!HP`)^c%T(reL&%ggg1OYDHGJzlveKD@ zVFgz%zvKsbg5n+gFL1m2uY!j1*&WsNbH$hx(_R+=y##{VTflc>hWb8ztZgMwA3M(F zeZmXM`cuwp3488-k<0oEQSlL)Mm5OHP=dN1x8b$UHm2Oqz0@pF9Vau?Ekvl2-m|8lH{ckzj%(V$p<+ z#vRX!XOMwEK}|%sMnbZOfe~_S0)J1c0*An5K)0Pmmk-4wyTsbj0)t>-w2l@M1R<0) zw2W;>+9WT5V<5p0iH)R_XqK2kZcv}l;aO%aTs|2IeF-ABAqA#tvi`e;wZlDqchUrv zjiff($q?VKMu;tb=L|$58=;e^Yb#2+K{SlDnZ{Z-G-?^`I{+hiw^rD4cS4$j}zU#=WXf}IsXNwaw&qkw9dV3Baw(nli zrY0`tDEznwltL<3Z+=+8)yTexaVvWJaq66eY=W9gTYrCATxfz*V|yYCCCupA>d25iN>I}m`od8c$Uc+xgL6vOOy@8K5Z-Dy zUKTGdG`mm;LWMMjUi6d)5F^y%EwC>Keq1a+ja5qbNFYXrzYQ*TI1&#>l<{ERqHdO- zXbugFqQ!UH{XB6RCH_NFy_F4U=4d$vdX=8JYLpx zJ}4I^zVgc8b;)$p*w5?Oe${SQ;E)!F5eTvXL#l{Dj6cyvs*-#Hf%=;L(T4}niY2Sj z#_&*t^Yo>Ortcn3Fo3B$iao1$p+}9#2W=ir93ipE&?^?VbAI909qCRm*@y~!;Skr% zmw%=EMz$crOxtLVrnR^%^JH^Tqvjy?tn^dF^)xe|QtxKg^3!iyS4Pnm2Siya383e; z@Mh&q{5F^61>JPI8DQ4i)1)T>|3?jW2LW85ZOZOvu-u?_QxYAWIWmNL z|1f!hTkn4j5c$yn+pgnivqJf0)E77dajZvurn}POm&b-?EZ=^>ZC>VWX%_q$04-5r z-Q%yF1B6GH%F{@;lQ3R3({Xor7bNh5`I88LgG_78+-TKk7nR;Q$N*aw!ledJg6BVGgs1@M$ zkX;(cj=iLC97g)RzJLGV^tu$wMb=#t$d>?PSij1L601@>_Zxb{&B;ppfi-VGGDu;# zvV18m{Ods=pj2xqV;g0up~-OuejjbNy<$CB!uR~{ahBk3rxU~q1a!J_n}6C$KokT4 zNp08CO&nnxNhQ2So&nnU87Br82aB`d;ArHGG?)ZERQn=^E)1((e5Snpe{G9D9{V&` zDh7ifL5LI0RiNk*08Kh-ZY=c(SN_*Ts$l!ZBp*obDxN3b3ZIQixoItxxd#tz0P{H4 z4VYlrWq)bug61hthne^97klG^j%;GrDf+dVPWXj^Vv}^$V#)5jnLIN&AdsrDBKq4C zY*hxrH}80tKeZCs>Aj5$c1^qU1_YP>4LLi%ixc)Fi#tFS?FB)}Y0lt>VTIxtxyFi( z?e(62BoQX;SZCAV1N%mJVT^tnX?I}J?tv_I2^a=@ug5P!-Gn^3-C&IJV*;Wr=xnr5&>2HGZg$y);##x!XVl(`%{YNg2*oi>5dA-5Y| z1yM}100JfZCJzv|fv$8%Xq8}oiV-6E{3$co4fj-|^Sb^mz@LNnZCn8JN{?EEbHG(8 z?`Fj>h%BS_j?AhXvjT75U^ zggcm4a5&$MdnridS7p1_SKL5@F=w?{iO%TN{TGRb1Aq|JSZmWtP?AQNK(QDgno6R* zMw^|EWU0wjZ^u2r=2-G(*5o<57zdo_BMlxj>GD7bXE5? z=RLW|F+=MNKQRIY3KVcUM|=+1-80k8HLTDJ1M*JbaCw+Tbp!Oob~^v%04y-zUiUq9 ztR#^0YOd*E&AeFn`JvJw0tW9bbB;4BEI)(Z(ItET`2zUEm>Hsx4Cm{b=s*H2tX8z+jP}2B>E9!a$Ip~M z1_SBDGmMSbfTS=JejB=;UN zyIQV>X^6WUN~29t{;2_{8J#2!?&ux!3t1k|lsaXFeT>-VS@KYz0AMBKikB7wIb1o{ z5eKMdMnWn{&k&$078Q%7p`$hQ>%WoCWZ|{0r)V+bdc=05 zbcu6@9k*h)d4NwOSXbPWn(*#?wdKkt+?UZSLx=CC2m*r~Z=K}OSozRu=G#Qeaz=Po zTsKA4dV1@qo#`1Kas&ce_tp%IG>nt{;OO%a_ioIr`}b$x{n6omLX+&0;WO}O!u#SG#E$i7 z&YFS3-o?_7MRfi2u(n4O?GHK4gLIo@oC&?X2r~N`Sc7wnXcv%ys5Mj5*|>Ed3g|Qd z(rjSV@*+mY+#5wzT}HZ65a&~?wVSZO&h)-jV$40b5;{hn zdH;o=E5s~1@s|BWPb4sNsp+lZ+p(j_*2**@bBJvgKu-59@6PIL2veCS{#&=xeK(bs&OpZ%k?2e{wXm?B?M7^fmp#|!8mPg8Y_=t4g^}iVVTHDfGvNYeK2o^wT8CI1|T>w z0)^UuGrKy0;MGR=do$Ay4B^Bi;r($hK=d8-{p!$g7**(iybY1;m7N>lmm>eTtZb#I zp&+->AHnU5O~{~dW$CYc={QRcR7$y+$)2f`x{C_COoY1~HK?aupNd~ywHPoL`IQTy z34)SHLJ{Vg4Sq51)(TFG1Hv#;_^H#r12p#SKhARmrfmc_*;?RqvUnb8at z!>fgZ<7jM-TgLMUV6zMAO3bEURy>_?VI$TNNsvo!h4^&7}tW9dg+a>`jfu~vRp9E}{25i&TG{)qlgC^{j`bq#U&6!&0b;yTCo!dN!nM&mM1P< zSzGuW^_r7ORg4pn3KDPf@$4|&>+FCTNbWV+7EoXgH>Mk3Mf-QvHR#6aF`4u+!pOUi z5&o43n%{@JiEDOde?b=$%9^BBpn`jjYj&to@Jg0m<|TxbwM(Wk`y7rMIWGU{v8Q7C zExw=A-spCcigeS-yOOeVzBr##I}lhyU9y!T2lek~t-Thl%KsAc#aF$`RM;j)E;*|< zS^4G|Cw}7uVeG`oTp$>|Fc@sy6OVk^M&Bv_e(WPc6G6ZJ<(tqxF59atqS&LL-@ukq z^`7?^ntTWSS^xdxv)DKp{PfkPPRIDSEkcp`@hSMQA=}%eJ86Pqr(Sff4_RR zXr&`WW0f4L={Ph)V5QGlp74=A(JOev^O`UJ$ydFF?-WI*z*gi+Rc4J753sS zJ{P;c#`Pg$s)j4^zE)ZLX&H|j_x4-MhpJU~OlBp%m=k34H#~1&+1EH-lBwCO+Dtj~ z3})KzCDSu0q?+>?f^gZ{2t!$`pJMB!3|EbMS2UT})xQ%s+ho+Z&L85d6;p5DxNRB= z!ETS&Gd*LBjJIZ1e?L%?v_BBRm4a&iZb!#wSR0lU{eHphDes}=arPhLRk;Z!a}OL) z1@rROZUU9p>Jom_)K{oP3IK2>3K)SFIA)g#+Kt2s*Xc?y{Hq#?YCaG8RsC-TqkkJ?2UD=%%~v)@5k+&c4fI zrCWv+#DD>suW>WnKJ*0rH20iBQI=fK^F`h^ai|jBv<2$QgFxoiiIP3RT8GlE9R-)A zdjGI@X$Y`8`W<>}41~CCuHR?f6GlnD1c9PHIv20?WE@`q$^VpdG>rOnh5Zjf0RnyL zNPck^;hd9v&9R^#z>ey8Nelu}`%rMbERgb?A|M+1_!D{le!%R8-yF{#1`2&$pnu?c z^3?0_a!r{wo9hPs9~v4cFi*6+>P5jpX|b|hiK~aYL}VM@ypSjwnEEvZ7yWZysG#qW zN#UJqRt+@Q#a;yzIvX*_K2ZJbQ+!XF!UEH{SP96ydBRXTyDia?Prb76_3O;mTPMX& zUU3)L|ERb@^UkAl-TvnxBgGpOTn)gqL7or37eu+*I3Uo!7lsR`JpQU%Ixn84*zOQ% zg&ohn(9Q&Yj)n;>_vndUky>Nb5o$4F_9{|#mFPW!ThowdCJAaLdG@w8U#;kSx^IJC z^X^zF(ZQ(_@`LEZC&L0(U39AQsmbwGvg|5$U5+|G(F7MZb4~D)Cye=(`(s@+w%H)t*j4EO!=i_|GA(RTC!pB zb8upT5Re%eu^gKI&mGb1^ilK?OopCsUE z38$Kh_>9dbs~d#4+t3M!mf|W)w^AzxxDW4}&L653Yq_fh@2w)P`_ds1 z|A=QY-V9?^HVfsyJW0T0>${5Vqv`?oYan+-;EFi|$6$?P*|eL~$qh%`&1WV4?o$!N z`b4#kcB=Ae2!wAk1pt1xg`>5 z#zsBwT@iA_n>jlqX=fC^?pE)=@_=-~6#c-ksR((XANv^>O2_pZYXs=3`?-7UW=c*_ z{ILc-3Yqh$SVOhdXH`aoU<>b9>wbPw z7pRKj(1}a)Ta6}lVY9Pd^yyUgQpfl)o2slGGR&K`@;>hhcZtj*k4y#s1OWdyD3P=<{<$PyQ8arv7dva<)nx7qbYU@XQul6&{4JD_BJd8 zdKZ2EI(YxpDlzXoE7sdz5+9D61OgxTRxawIM&vWvGME#VqXfH5WH57}hAO;m{gk;u z=n(pH`=TDOzBa%$vE(kB*=q1eU?wDYe}dA6xE^7%71qpcTn1C{h%os2-N={DhQzD{ zl&PyZyCTeohcV_pJ)K%}U&VnT_(h%@s~UdM00`It)xwMD9HmGmH$QL7s|sxgf8|S1vJ(^KA&_=C=b((F@`xVSV3^ zTIBp7XQ%j<z`QfPs*)3L!Rx%rQQo{M*$`KAB$#%N2p$5`ByAmhUZpd1%!Ul3k=jty zcSk{H$(=Y4yX+z4HQqX1uD)b>JU=W_Rl=Xh86SK7LC{xi=Di=fYYQC4pTQP&Z56Kt zMI;9Q~Qa}39j7d;nUo~VQ#A*Iy06vtB5cd zvl{}l)Z7&y{)Bf+InhOQm5-g)G3BzJDW-h0IA)4!!!a5Sm}*|QArJ3ewR+6_=rqQ4 z`nsIIG*7;)=Y?ti@w72Q=$jOtsFq!nVb($401wM!E|)*beJSn{JKS!HVO3+nY060b z7GDhqza}h@CrW&o2VAfte-|x_ozkAym@+kAl7i-4Fl@~IB{)wmSo)Zu>3bR;IWX{~ zKkoM{<73+#I@CdUPNskYwd8fZxGf6A2etE>b4k!MhOKbR@<)l~%!_Ofr$N=I+QOKn zN0@NW+g99%ueyZ+AjgVg=n6(fjOjSuhvyASDLmhht=eilHyCCcc)*`@iVx77(=$aI zlVVr@TwO%N?RCjT_!RDd1^44o#FRvlSFZ(|KM52SO^QKZ;5bPDf$7FpPBe77EN6Ra z^mJ>BdraOVFwG$RN0g<`X};GZaFT!HF-Apf(SJ;p<UEKXPOPi z?b_FvqWkPC%W%(zW&eJH#Y8mv@)+m4YX3+-l%Z_xd<53Er!g2(k8a9*4GqDz-d&3O z^_C`aMO5<_At1i@uCqElvJ?c_Re9ebbLYdxLyzoZ+f`v}9R9P30FbTuLnTeL7l&nZ z*#lX6^*sC-7c53>r;#-3w>Sf3eUNV}asZ&1YL;`!uqrpjtcY0_+YVpJmQ8b1)^XuJ z%sK8c%g8q+97;PPrK)-5ogdno1vNlYXJJw2_en-~_R1K&oRAtVx|uW>wAYS-{?tX@ z6oMHP6bN^$xGL?FoG)!9O}_C<)oE1P=TAE$rKTwlrxr8SIT$mWnghgJv?Vf$J#58uH_bd>#2JTU7f1ANyueJ^$a?NU zB(*??UVWf#F}g9r-pyQUb85mdBS?m{{JIae;n%q!Pt)WM0iFYmB(Cr)JW1jh0mY=x z;5n>(Cai_S^b5ZS^gTTun;$Q5(8s0LFEi zrj&-}CpeyXK51dEK6%K1gP2x07<4E8imCHQ1rO&j1?k<^KfqZ7!P#CE5OCcMmG_N~ zQT0R9Rq`yTq(L*H$s7*%!x4H?nwlkWyrsA?E^d8gZxO-DWlk6>tvsRV%!u6;otTv7 zt?0*}FgyG6CkJ(f&vPupNVQicTrBn<0J7Mz>gYH6K9a6 zrqs{<*1c@wwxK?UV5)4|zi(`~96g+cIB@v7G zeyRs0oa}fyMS<0l&@d)UEe<9~<}H{~BxH3tjF7|*i9o_RpJlXe17zvpzZ;KR?t6AR zHb7y;ZA#Lgb9kcnBBRE!ecNpvvdpz-4|&{C=yd0*vd8Irnw9_nm?*hefd!{n?`%!Y zaUd<7IvaX}&VSS=+0$7R05&cj0^2Aa=L7A@$DoNnd=<1^KS|vakGF72|Dfw8)Sd2| zW^`E0qE9&xHr;G(q`%4DKBc&+wat*^hju!9M)NlrOsWwhi7=ov&1HktI);9pey zCmLlJtl>uiwU>2e9rZGg%0`DYj2vf2Sv}$X*oJ%IsMe%2I^#Oq8x6E`i~TCk_T`#& zspLJwrzTRA>n2$7MC+^Qxt%P%U5QWM5Xm$WHKS-r4)Hw~kfY&LsaFlYW4&A8;gj?A z;6Dsxg?(BYIkdEb;|n-%s`K0X-Sc4n$Z&r<>Pw71hX{Y%wpXve%1xq(xDoL}1e7`HO z3Zk$f@6|5W6a1+yp@sW{i0ZZ6koHXP4ie#+PYZDy@%iNa)*%FKmy**!U-^2W-EB_nwDN zfL8M%C{IB4?;yVbD=d_$MSPm`DHs literal 0 HcmV?d00001 diff --git a/public/Intellifarms/apple-touch-icon.png b/public/Intellifarms/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..408dde9ae352fcf86832b31eb079cd44c1a3ff56 GIT binary patch literal 12842 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 X5}r}&znI_u0RhNMt4LK!7>E21Y@=mR literal 0 HcmV?d00001 diff --git a/public/Intellifarms/browserconfig.xml b/public/Intellifarms/browserconfig.xml new file mode 100644 index 0000000..f62a123 --- /dev/null +++ b/public/Intellifarms/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #ffffff + + + diff --git a/public/Intellifarms/favicon-16x16.png b/public/Intellifarms/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..f052e75697fdee4fac0ca7f66cbbdef5d4920d8a GIT binary patch literal 712 zcmV;(0yq7MP) zK!5iGGztgE)Wis-%5Uj6#mcsJz5;$YrNf zz~-U$>d7O`*0aFl5LZk-;U1nzySKdh7Klm4V}bJ(z|rQ^V>xUW(uUp}oxHrQ5f2Xf zrCGUUB|VeHmPPE?8Tw?H()~T`hj%yDzC1Q|?;}KdQ_}Mz%5=Tzlz^}S^|#wAmv8iW zadFq=L@lL9uMwY)rTRVn>iXHmW^1iZKHv)a;bBpE5UBtRl_EgP%%&^j7n^ck0bUB! z->|_~D1A{Q-tUK50vDWg;wJ_B3P4o39A+lNIyGaWrqtTP&W)%v23DOV@oG(_^uxR; z)jZ6AZlGLH*syiSFAL{y6r`!`hVMw|#bMZ0rF5adLxZpiqT6??koKjZAg&kCU5&NP z50{#)Zh#!WJJ6ntu!x@UX_J0!q`aqR)%Su9K}g!>_OoSk;tAPkP(r&_P@XMR{RcqQ$(t>q<8E90000*ejv23NTDWL}*?`14#^I34r- zSlrQ78hd7`b8j2KnVt+cw_aoB?_A-Mk50wh47)oz?y82&*SYkYLboqd!jxrC`DDc+xyAc(**!fc#{RnaTz_-h>crfZ zRN7RKqa4Fy27_JWhelwIsAKFq3y98Tc(A2?tq;28qjEaRHMq_w;Y4pW63+Z4} z`x+;<*2r&k5;Gy+Bd?uXj~_a;6cC+d_$&qCXEADE`^kf&0R5}l-jjN*HE|(kE089G z>E*2c^nQ-MFYn{oe8B-1*jDG0o1+=C79KHU*Mb8JKwgW$P)o;!M7E^Tmli^p$1vPI zargy*;l_@Q$Zm7aT@H}NP&QF<=sYtknkmuHRl7+rOK2DZq}#*3UO4g$Jf%e&3ey zgI6g{uKZ4M?PMZ;8ihsdo`Bs!w1=X(zW_GIK0A|?eGvN~?q*%On{{bd>G0F9q~kZ9 zj@hJ8ode7;cjdcYs@+Sc-^57X-`uu(xlbQH6|;R^s?x&R`z&zY4mr1ma6q$aPxYzO^jssHn%Q+@Yu1k*<1x`u-JYStf89LzPs<( zNO_((zo~WZs&r+!?)0gen_7JBo{7A=wX1Y!*HBZ(j>M@a$4C&>7!FiRw*GwT>{Uj> z0*JZlLL25eFjQQ7nT#!xU7eXd=HQD4$CjK0!hSs!^ZQ?Yd1ZWmvASm>RG;rE9ojWm zY=5M|CmT=a`PQK9(XHbz{#|fp*tM7oyNVr`8@AbqH>b|~45L>5_DG~^mY)>?11%`+ zWqp!1j(;Y@J)NXV8_c~jndjTOCq{<=b{5xO8bh>G(R1(i@0TND$$qXJ}_$`PKQvDFk)UX#mz~izadF)DTPYNEe0Vk+a^LK6G{Mq#e%%6Sp2Q9 zpLla0%bh)uncX9kPXu;W@y?5ZzD;Xd)9i0`*cYNnCu8-g*NLZSW;sRy@^6Kl2Wy;n zRibHr&>5IR$n3ZrM?AVRi*Xdl1ny05L;`v_oac7QbIY9n!|6!HN1p~V^O6OydY={! zocQDDWzB8Z)yjXOY-uLlr-f!-+ zo}{&A4=K(W2(fdJ+feyoeR z<}aJF`E@>1GLV<*reZ!`jn;)AU@;Q&8aX2I%Y=B{a-VioEJWseKXa)ykTQ$rack<7 z^e0Xqua)27Bq!PH09u-<3QQ2^ciY5BHqQ|SY&s`-YL3ko3-ZZi%nrEBJt?{G&scba z_YNtQlxS`jFl&KmP_ftNRRZKf;m^lI)9|Ww<(inQ;^vAY<}Ptb%yX+0kmL#H8{|MW z0jkndvo5S?Q1ZEH3l}7Ce?(ag@>T4|0sX>RbWtuM@Q45?QWLQI05s6lURvRj#%T*M zm#f!$s}P2f%Ha_ik%HV@NPvJ*v51+KB?`-wBnCK%a?FWl)92z>gHP+HLiUeDux%#h ztD+`!6~S4~zr7r=K+)XVy} l{5twiOB*fc>VfZ^{0kUwylOA+!)gEk002ovPDHLkV1h_&%Dw;q literal 0 HcmV?d00001 diff --git a/public/Intellifarms/favicon-48x48.png b/public/Intellifarms/favicon-48x48.png new file mode 100644 index 0000000000000000000000000000000000000000..948fec739bbdf4f2dcc035830be30a64641d28dc GIT binary patch literal 3117 zcmV+|4AS$7P)PbO@~Y5=JWxOsmr9M~8pqgcT#}TSq;c@hdcF2OX6D{=e*NR# zS&!H2XOK!IzS2s2=g#?^@Ar3pk8{qzyLcDxXh`z+{|~$9va|jFjz>M&F0dpkpdh>A z=eHGa+wyjS-34Ai0S5Ag&l*fOXw@kLJtGJI8K4);A&^0EZ^yj9vVbnI?Vx?&0k-Bk zf8PjejmYoV#C(L}RbK+OuP+_=9>hWbYU3^!mn~o>fq}L~%Z%z;Bis^*K^3Na^g0kU zn&ABigz8b{`>L|7d*sy@-wq*{EMR5`Y|RxuDkAF;S(Z+Shw6?1kEF8AkDr_@r5bGG zJ*dAYCH6f6RMi7Ki@5FUhY$Tb#JMxXpkAtwix$uera+f0XV3u*T-EX0#wJLf?Myw?!K8)~HC;V;Cv4d~L~_ijwvXKrz_#3iYgE!-R9KY})0zkg z1=*$IcXSUA?Y|Tu=M3rKwtSd|u&ONfVp>@r5TuRV6~)jHBm&haVmcY63( zvk6+#Vt~_uD}X_8D92s6WMk>TD_vk~RcvOCZEtJ8$$PU_g|3X)AQs|BfUiY_k8CU* zK3Wsf7Ym8e+k8X5A)CGjxX9{_+b@9z&{5pj{NQ*HFgx2|l90 zq>uKf@}ydxsa2p~1+frhd9p&qeCJ23vKHa?v`P95{wSWEK|1}U6!V?WHyiu$DRl^- z$_wh*)_wfI3sBoCwA(%oRg3w;ofh*%vk6*G`S1h`g z%mEDq88b$H92!~oz+12WRO7IkwW7=v0;fCl*4ECC8^c=d2OTU{a#qnVp4f4LRCFs z+-*s6;)7$=a3}D_a=I)@n4Awa;d>L|t^*K?dDGzHqx0^K1%3S);FRC=J~mFK^+G15B~Sq~@N|5U2X0DBne7 zkBa+61!(rcdn=+RHkJ;&5|n0$1}K8Z%+#(X`0t8co0nSH+`06Hg!_Q_i~5>72TF;+tCw z3)9Bj>(Q0gNQ3caQ!&@^Jw(&z6>u7nHyXS#rqzQ{)5JG=o91VATz51{iW9E-^2U*& z{)ex-E^}(M@=q-`wQSr+m9ePupq11&C!0op)^IYP`One4opL#EY%% z?SYtkRalu8lP#-b+=r1MLDrbtE9!A5f|ywaEN9RIbeCS+_opmb@C189O3?dji?1Z* za+4aJRKt3JQztTOnr*Ob!bfEl`OJpW&~pHk0FIPNO+)m6o6_t=5azVi^b zV(vp^MJgdasgV=JNF453R_6r~!05~zmGK#KsY2#C%TXP*xJBfPtmkuB60|$fgPS^PR zys#oICNp&=@%oDZNuH;-*yk0HEVB?VIRJe}UoX|X*9SgOvtlfA*M}qdm*e+)C%3j0 z7K=Ci0dl(+AySV`_Cr_L)T%Mh2L|ER&8>xnS!>of^?!5$;^)K#a-g;lXFNzE&I2Up zfcPwMMSE*DZMsEeb;_8`R7^$y@hF12DRqIumwVgVn*)`9ON-4EZ?86J5&?HH~_?{=e1L_5{HQ#YpW7_V^8oS1!HS#u@LqL62f}o=Ag^i_y z-w8Zh8pP(ueOLiB`R-ZsH%efeOezQvX*5C5Ai)_(x6EDizLfL#OvMSAo`tv)=bcXCyVd)jin1Y$bf>j6p*$o) zMnD5dHZ2#~BsC!)R}A~5thJxD2xWDoL`Wyc%{tmpTIwKN=8af-9bz9onKioj@sq*O%JI7oKLRxJwVZ8gkAfHg-U#&9iMYEbX z{E`IJnorwEX8#x1d&i}Wt)V|(*r|2p?flTd!Lbd)LvIvw9itP&;Y)Mh+uAfbF`B9k zz9azmhRTY!S3#aOTG3cP7Z2j=do zIwc7WNiHO;xYZBMTau5e)%(lpLS_ja1wGxQ&P3V!_;XP+<>Sy1Aw`lxX9#fBRM5-P zpmvKkJN04HOPd!;@&3qb&Z}nYzpSXsN?<3w`^N`HUfWm96~5bQGtCnk5vk(pO11(i z5y{MGP<1%#^9di#`qHl--8*&;BWIzXLRA1YG6IsV4{AvcU)J&nfH!vFxK@fH;=Si9 zX=8q~s?G~&TCA;l_aEN)zxFB?@#nwP^(L#Lj~L`C1q}>>kC(5hm@f>VRCT)3o{ic& zRk?1$N6spDD;iYPIgO*i3HYjdyk~VWSNH_pp8~-ND$wH9$9)1(RYf4Ts%LlaMZ}OFUl_8o#q5XV@&OCsIHCT}4TW zN$gy>J1EM8ccjE{%2i(pJo?;}M*khD`m&2I`p1QMcZihLIe^*fdRg6Bm$9{RGrp(e zgqQG}nV1M~R2jPXG4D`F?O6AJP2_Sg#NZ|0F};g-F$4b#?noGLD9~--00000NkvXX Hu0mjfL--)C literal 0 HcmV?d00001 diff --git a/public/Intellifarms/favicon.ico b/public/Intellifarms/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..f3b5e0c1d9dd266187ffbfb14377ec4f7e69b25e GIT binary patch literal 5552 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 z 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 lwm1@- + + \ No newline at end of file diff --git a/src/assets/whitelabels/Intellifarms/IFND-2023-Logo-White.png b/src/assets/whitelabels/Intellifarms/IFND-2023-Logo-White.png new file mode 100644 index 0000000000000000000000000000000000000000..0afb3bf8e3eb984e5755d021f26ba40a44635d0d GIT binary patch literal 7766 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 literal 0 HcmV?d00001 diff --git a/src/assets/whitelabels/Intellifarms/IFND-2023-Logo.png b/src/assets/whitelabels/Intellifarms/IFND-2023-Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..11ff1c80c8c4dbb7068e345955d430020324996a GIT binary patch literal 37147 zcmeFY=RaKE_dYyGq9wtI7G(^hMHhl#$Y>*45WPi@9-UDWBU%_GQKAPCz4stmhUi3Z zL87x1c3;cK_EO* zqFcaUpy$3v$eaInX47(iKVl-6|<6~nT?gEm6@fFOShFI2o%$#B=_XG z_ta(@p||1a={2V0b1g5|$YeRCgy$_^v=ph;2aT&Ls~sw9+R5qJ*8F8 zo|5vydIcWt_NpP7+xMPIifmu|XWPe6btkx&Sax3pq_0SZTdu9nxQ(CAc@g6Q`}zOB z{y(yImH*C$h@6=l?f`>3vEy){CgIRiJ?^bAE0~;h969dA z*nex?APUp*PEzttm2X=zLy5DurQz;`$DAvnFPI(@{kw!&Mh4a<3tiAav}M0DNvD^k zflqN2Duo#-AuM^V@BO<*CMbxYYj9C=>iJI7!5+#^r<-p!Lg1^;&PLrrYr{uWufV^{ zfOy!GEXvL@gaTlzthlr_#`hV9E#WeH( zwZJwj&LtxTvmf^15txNPp1l4V+7bGHm%zCsRbZ0+EDJb>CU^GkvX98H8AZhJ(@=5< z{=aLOWzNvM&ye2l<959@Rw-9nLvBgueg7~J_! gmz%3aD5maQ(K1pZ^0W#k~nLD z`#)0If~T}0jRv!yM=YJ~_k5i6HFYl)63m7Az1}m77oZ@y(yBvQ=Xb#@&UIh6NSR7Z zL(ic9ErAaU=?D$!z(&E~V#Os)E=8?QRu2?V{%X(1pER;`5xV3cW(5J*Vvt){DTXr&HQea*X z+R|y-vHRGLnlZqao;a2bCYR0ueRD$o-yZPdcqVQuv?FY~Z-ALvauPW<26Hj&p1i~u zZYA%c=mTY#+&wT0rBc}Yp`OY^TEg%cZ5ZNnEYT^g@BRPn;-Gn#7TLDMgz{mn7VhTL z{y4aU^yYysl=F`ScTy82HV}9}ItyP3(ioF>yeJLeXJlq0;|W z2m*z-{5?1Zz_|rcK1p|tT~o2r$ET8B{3V5RUyi+%(6mVO?|7nQC4T3@Y;`bn8j0p% zq1fY5`yaaDfeDLl$@91-+=VW*mT1td4zgi=p2zIgMWM02I3{UgiV$>{C74Bhu*c$n zR+>1L|JZvvPs*UhiB0Y1|DNJg#z`5UdjjA3Gk;NH#zo05TRGvrF&)@aq+b=At06K> z4;hxKjQ9;jiED=PiNk-F!sMJ-pu=db|J+X|sDn@P9pItN3pmLph8)>Mh80|)b%q!2 zQ$xXQQ@vw-gt&i+G~6<4kOQ6*jz-cE$6}FT-Y$0kZGb4IDH9GdD6DjV>zh?hQ5*OLV}@WgFVUztMPJO)?nyy9MLJa8rlE$KisrSZq+~h#Ay_( z^V;71V= z?Q)AtC@_S9KK8S0qNA|*L!_Ui>|!=A)iSeJpdYgPW*>au;en81D4(HH*u`6P7Y~>v zO3e7*0x~T$%nU2QZr#C5n86+v#)jWj4cB%WP2)W7+FMGU;@uAogjOE2_p(D>Ihusm z2CbxsV|{@w$ssy|NB$;H5a>Vx&m4#KdB?GUQ%3oG1pI(%p9IXL8?$>NH0s3@PX2_& z@^*Mk-s&IBKO3OHP|mZT&I2J`zzoR$GsBnvfzVkrsQhHZC-rHY#oZ>! zr+}jrZyUlSw^_lH4U*_wnm2LD)tS;%6@eO{x9*8jt~pr2IC%a_|a zYy?sTXE&O@>H!P%XGfs1jxfoqX;ZJq|7=i14RLL_YkY=8`<iwD3V_a2E7JuR$(V@ugc^LpTXM?)(5ywsD2?SuL-G-2N8C|iHxQx{dPrFqjoU6duzDL;OIZzlLeu`EOexAVIp@=CPGOmHy_oxlFBE z2_Q0_wk?f%W29vTla-88XFj7QL%P+r^jg^-qQxG!@zs^hKd>{h^?O(yRi&jPd^R(k zFgEHpO^T3%LR~qDDWV(25r4zj@=xvD*mTC1`CV}}@qf1?ikZ6yQ2;}{;!l26HK00t z+@GZYaK;6O@S-rH#)1#5qKEOp^c+JyOZlpwbr|y2a9nNAGq z&;@H#2jQk9cJptm_n=rXoxBh_; zfI(yi-uJ1=IC%k^i#17Vh4N|Jjlv{LfM1hMmQ)_neh{+h_Fg}Zo+D>`f-e^X;^M#) zDzE)6!y*?$L{~kK!MpJwEjr{-LbCXn$9`hhEA{Qa{gN}INXer2UwMb1Xqvv7<~e!A z%OnAKEzTGc3OvT)PyW0s#*Et%NJ{2urJ&n8Tjeq28ptpP1;p<}M9WDG8BmGHB5tfz z{9BD##+?GP>Et02PF7-GqR4}nH|qaI_K-Jyr{H}*%<3Vn`1zO&nDLk-klfEeXe-ka z%$#;xz@WI2p_3+_+$YY&dJRDM(2LjTE@3cBpBV4I_+^$cr(n7TX5xt7O;YB^3}q=p zs%rs90lem^;(`lV(^tVsv|uP0P(mSM_#Xb@-VPlQytqU`7cj@ z5tI7gk}UcHmAkY{ln)4buZ=?ae4q`{|3zaqP*wO%ApLk)aIsZwtS{i)NUpg#TRjq^x$9)7=2INC!>HZC$7S7+2cr%DkEp&M1vN zse9d_=wAUr{>$`=fO$+-DW#Vl(cO0TUvf%QLs&AZY5&jXWENU=Fr-NDR|dvX=2UeN zr*1Gfb==7WbUN81@WbKo;@#0>p(e@ap}2AhT0+$pWy%vcZ^JlGNnwI3X^&3t@@X`& zmcesp_QN&XgL=^;1t>!Q*8SqOhm4)$gQ-0C&N@PcjtS`s=h?wVI$Whsx2tuv0JUDChDVD+AAh3$n@qjIQ1y|X_`it+c{4mFMG0N> z?KZ(*Lmd#{ebSfOJ~$m&U#D1UO$ZSO>fqRu!OqUN9ig!u*kl;|>Ic8YYMUzM{tESI zqeE8V`a=eG*V{r1X^MDeLi5f6PRUEm`2Xq$;m_;PX?C-)sa84QF1s5=okyif@ zF$Sa*la`{MANJRmL@=Tp$-~JWhGWhFj|yAZU=)V7068NGjpbutY9Nhu0JGEr1+g** zZVHL`tpR4i#F9_aUtl*G8vmfR2Dn~2+y8lbH1|?M^nO%Cp{H(p9^`VT@Z0hJX8*e- zwa50FhKBYZJsA`|`st$BchZd|Y66>GPuY{ByzbIEWS}nSLaVE*tFW%yDJs@GUJ^A< z=j;1qHSW#gubu@QX<*ZJp88LjPt?4x-%jqM9!O9fP3al;YfhV|ePsR)7t@ZRmpylX zzdL^a=?@n}9-V=P^=f*%2f918-=psvTBf=W#)i1RBg44NC`68qJu|)Xx271vaqRTt`%OjndYHY+!yWM?wJucfECx&0DyZ==q! zMtGbiqq)TiE1F^e7F^D-zQYe6wjOZSZc4Y5g1X(S^w4jA%WwDisG-e&d2drATe*0$ zeQmCvuL+fBt6M?kwvm0fG3Mkcx}g)fewHd|fi)1?pT*I+bxbGZCK*tVYnRTg+1ig3 zj!f^95RyHbBKe9C!PYr`zTNq}`I~lqVgGdOV14~K93C89&vlT!Oxq|hb^Cj@n-NuY z9~{GHk+H?u|MVfPk4gpKM#N-hQL?_Fi6Yim&LU-pGg;1_%l8J#?87dHnotb;%ba;V z)sMf<=Q-UH-sK!C+C%0Lf%C(&AeC6o_nrOWsXE6lb~YOV1Cr?j|J8yPQe+RnkQyPI zHE#FU4V*tpQ7PsMlCuK9QQA#ymKBzjtbPP|mPw=}1~iK5tt_ffvuN2tyft+FW1= z%g^tPNw0ZvmpXQ-W_-_z)H9DnvjP+9EQ4w27-){rM;~vF7b75v!`^Ky3wP~WhD`Fb zmJAehpB{AMr)b4-3{RyziL#fweEtdZq0Lm%QxzYZmYep_=IKE>g5JvmzauoN1G@x+ zt0}+U#|dwt=%4x#fgzaTrpvEl(F-jASiiN(MX`!*N5Zl}DZu^RMjKt}2D_@LZ^`>gMN9kdE3(Gu;E(#5;-VCZI z#0RbsyN!7%=xCA;=+LBbS%7o-JB6=T(Y_ES7P-8nF2>_O^m8uq^h( z*m|6HagTN-ajHp>;j3Os63C@A<-;oA*XBiRZs1}i6{Sbx1l67_vw<|~J=`Y}#IbZR zxv~9AagqQYE;uU0cWWsyAv-Cs=xG>PN-O7b-2)xbb4iLwGB8tU{BGU5fMWx@gd2{0 zz8_z``C{>M>ykMFK{Fb{-KwfS^e{|%o zz)KQU>NzD0D#SoxQC9b1VCG3?Vr&H2her;g#27*)Z^;KxvG1|J#4=2nuo6jE859fM zfvzzTbNm#QRF%%8kS>AGUC%7z|Ia9{VPL&mO*VO{u$Mb?xKtdaKIO+Wb@3-$#KpgE>E>wEaq zqy~)>1Ud>f;?Q%Xn54?}&b?a#svBZ0|nCsziA)^C=2@W8g>?Ddfh_NUO@^11M;+KxcivYn&2LvmA z0umJD-AYD267Lh_-a@HjwG8U}pI0smU!RLVL!vK2 z3C{N-~^prOmD&=<~F*^VsfF3LFQMuKA zCD`YoeGxsjx?ewpx|~(ZGQ0Pd%yzapXf8w1u|(Znig@iX_ow=bU_g49P`UQk8$_g- zJ5^AdkxcdMI};_xWO^3~WOug>sPa!TlWjSM1pE~oPhDW}BRQyK5zw$#BggbQCl`5fUx^8wTp^Q$l_|+u(OKr<08gf%b!WO@4ljFr&n_C&3q*A8hZ;H`i}haHW+%o zBzr5xLASST2Sc`ipBQ@Jf`>241Co*aYj%0M8O-hs2R%FP|VC&KWw`~>0p_hCxh6o^`k`mH8 z(wX?XKKcWD8QY({p|JLId}D!n)8UjEhtpN{qmB1B^?mNq#bUEY#UA{`y67TvDMIf? zmF1;B6U6r)V zOSSA{L%#}lSQ`4bG`*zC18)YG5q~UZz6SWWOadCKJCur3n{34e)+p6+MlXEQxP~E`tyegP`CgKU$yL3}`^X7}FmA5nY5YwmF zNs-452$vV0inKUf&AR$iOO-7DzSqBv=J^+zKcY-h?=MN|MLsD9$ff2@9sk&qK1Ck5 zSnd?S*^!@Zve@Y!Eyv1$D~g9!0s?G}RmGjMFW;E`48R94Z7^0d_Woq)ga@n9yH#9U z#t%5ft2Yc%q%llfnga5UgSdEVg`iaE0T+zHYa7>SBBl80TZu(2cN+sY!!Ni^b~hH=k&8`z5NNNKv-;g} z&!;yUa(^|!ZF+ah6MAF0?q5^z$TYk%G|8fMs8Yp|XeY{hRt~T(i?ORz(DUL9s^$yB z^@5qwhEOhrI+tI2&L3`dascisH+DRD2UGCbg`GPdy3S>wRt)d(r8r+s)--#A^y7rV z@8(gS#S+2$Z7#7ePQG_C|N5}c19Q;I>#E3A->UPu`HlS||ICudpUmIrM|4ZhYY644 z9$LrlzGPd#5pSnl#`V8&*Lwm2p#eK(i(_wV&E!n4+s-<}1XVCarm2*NS*g9Q@YouxF+ zkpvrl?u7ILVhoEUs=siu7sW#tFNaCVE2UTcfFl1$W1~A1!?6>eK2su`SeSKCuluD_ zGbAN2FT(nw9N-L6A~y=WzE?QHbY{G^_4n&RmyC60cp&%JOVZI5;@{bP&y0DmEQ~!A zyY^nJ5M91`%L@GGz_s=1;R)H>k%drK=m__vQyC~cRJ6Y|e~nT4-h<2S;-7C1+LiA_ zMknSQmt0;`QRGgWJ*a!(5>eyBQjxjutn@7P-s=-uwTvYKE(6>`mVQ8X=ZciMwWH74 zyQ#hTjt6ki>W#mc%eZh7EXXCG)=UT?zs|auks*J)ntCHT_vtD*I>5M}R&XrN=bho< zfudrw{YXc6091;=!J}yHZ&UzuXeTYL|bSTsfV)jwpsCN7#NW zP=C7k9zk)7pg34}kimIu&fGrizA>b`Nb;!B6+V)9f`ET zg3G<|XgBInZU5K441>w7f<)#Z5YgKu={*2Z7lwCCSXg-YQqk|jnim49HEz`B%qHl* zq%*IIIwTp5=_TO6#RA;UBLmuvEpTUfW9ypNdBkBh)Bu;+ifxldzAIuAteB*+qUqj# z(}^dAQP{2b1XKM@jF4>to#3`b3$TB*aXJgHU%QQgeC2>IeR#B&iz$H%On;{AL4F$l z#db7G?8Uz7yUH;a{I`0u;HfW7$SwUWj6H#=@F~}rb=rzkWQoiZ;bC5urBI$ChxTRwQMg2=ITrH<(#{JB3Lb~+IYvKxXLUeMtlbwtZ){T zR664DL@ahHiX~v%6!m%j&URvHBFakh@TILg zXmDcOn{xw7m=W)-x1}+vce9IoN`!TNhocxINPq7k@!L~jE_o@xpUO0aEpaE_CG@yf z7Z}4nE>I&3dEevN5^x3)=e2xcHpK z;3eTycd%;Yucdk0r)3hj*!hLhRHoPmpQY-X*BOt=@yv=RG78X!w_S!M0A@Pxe4ES;K3P3`zUs%^1G}D zdXcVuWMD`o(r=Y7uIdD%-3M3CtXPpFuApvXKFl0 zy|l^JJSQcOz}3hHDd)(0elm+o2r#5N_UkA9Jc6ZzSWj_dX1> zLAC#uDC|1PjDIn-Z>1n^i5>S!Bpk!&*pyNpP0Ae*INXaCO%==lS_G8K%Ft(*gn$Wpx=HgV-;IQKlTmw zsvRGc!(?za%W9xq`cD0EcLJefuAlm+DYPIXTT7T9T0Au8XuGHpK`=v5z}Fa_$HWV-oeu&yP0zgn7a2f!q7^ztpsy8r~-=rXN7{ z`5eqRptZ4=6Dik~)XF|hs+>ycJhfn(R$pW$8_4@(JURNfRt(4+8hi?tH;MS+UWaWH zlkv>jT05>6ifXUfDkpeX;NL%dF&1J3P(}b%C(dgqbQK&Qvq_3_F1Sm&?Jbx#wd(DW zZY|)HvQ#ipHn2jR`aJqNY;p==|9l*P7VXD+(i31xw_?ip*4^&jslCVDOKjG0efVu- zm}%A%22#w;*^4v_#bph8^6-3HbK_PD(vc6&DWKh3>xFUT^ z>zM2kalLA3&O>^(c7&tcjQHb}@$BKESPkD8Q1^VA0U%W8LyzPk8AXf+H#Jb&-HVHH zSuJyHTELKK%W*eGHSh-1uO+om1Jf>2WID%|l&UL2=+N7^3^ZgCBky7^o-# z^DXwp=f(-&7aR}jTl5TcE1URd?-1TEHvgRMeE`p}p(e)kWItP1(EP1(!8(KRYy311@XqZFESmh|Nct{9P43KC&6r`700$Z`#B*VqCj3-*KI(#izr6Bi zjIO6dqYqi>ZXk#CWO{FP)(z_K#S`u6?yH?!i3ZwGT_SHpURgKjnLb&xu&5izp?gOX zj-f>Q`BCuWvRZ9@N@@H7R!biSw~jw~k+E24E~z0Vaf`HD`mplwM!_cn(5Kf+gjTbq zVzkwwK+VE&QGFXl+W&@aBQo$~4C(Nd0n$L)sh^SiZOTY=w#a)G=A%9Gr7&H*Hkr5B zvM(zlBQU6b7(b$%oJYoTcF22y|NQv}EX6(nVSMZ0V}PlVdTjCnZ;1NK%F!+z#`zBw)hoz(-dU^V&Kivu$ z=U2HNBL0gM(!^xk#F$86u$Ps#0dE5h#=JAnaO?4ZL~F&-O2P7mE{C z9}nLT<0Zq4mQF*p(7O$(g6*U{$)4ROWNIzbU)lwkHyV1jb?32xGYPt!fV;&vAw<6W z=)QZW5TsIzf!OKt?E4|23JZmb3C86_R8&>y}Vjunv#Cu`tT;D(=s87R^34FlNKPI#vAx)o2MP^d@4 zPdM$%fEGP}5YJyomLtIib{JhgRurs-PuJNhe_E0b%g~6gH{B(;pAzF19_taZ@R1&s zGyfsY&$DG%6(bh;A;rJNwNsRRO&&Dq`=@?>5hx zbZ1NBRD*UGFFh8>mPtZNWe@$u=e1AuX|?O;&lYt2Lk}}u4S-p5npMdIby^%0>317I z2hy^!4DI0V}PYJyg(=qWPT{E1|I!c&m9oc;5bH*0_r4YkUo?nwX=hU2%}=NTX4a z#;?X-hrkrqP+^Mu8XpdUP`@51(Tm1)&OaEbM~+uth3*n&dLfUXA1+DoVI3^#Q>1MW zw8=dlIC@@gau$D_2SxOPBQ|qb9>o}rY4O+>)H&x5?=pvrMUIVE?Ka1uu>}E~l49Td zZaKHyzVy%FZ=WA=-)Ro?Px<9?Ee*A~oOp1d6KoP`)x>A>jakt zs4&G1dtWhWgzV@`L9bXtovAI1<%-oD>1P>tk|evxDmvHFNtV4*_aI^9jQtsHI`8k9 z(mI2a`;5bXJp^d(Q}hj+Bcf@lMUQQUZ)8;&y$oaq;Re$s$MuMg=IdszCep z%Y!?wWfi7q=0> zoF5-hcPQe^Xdpl`UPehGy*}^zV)^N=Th*;7(*krK9d55oC0P~kNW7t*9b%!s9xAIU zX+Qx8?g<}=p*@4LLL{UzOvT@L)y%$;Bt%Y>ECeZr!Cyv0e6S9 zFiUcfu zVvDtRO;*x%`@(pp6#oX&03TyjvPQwzyhv;p5`G`~v2_CIeQ{L9zF(h^2Ihm@;mfwg zK77UERy?$u3A~io8d8-tep=4hK%!W`4}%tS`IYuPUJ2sLJD(z3D>9|`VYaD2oB3ZT%s6?#F@!oY>s^Fk!jEHEX ztQx2b;0nI00oAv9h~E|!{MG@o<-7@$oS^%Q!My5@n%sq=`b?VWHZpYR?Ai}e&9-Y3 zTntIWSHe`26INV{CoDNdTeN`+pV0CCj^JkxIm{j)B3DIwBhixbEWaJWP+eP^P>+MF zMpB~>Qrg4T+P=1Ai^0aFCd{~Ud5(YjGVyRsUhpwN#-0(gZEWR1&<eE0RlP8~x z>HbnnI$WZeWIaGOof9sQF|7psI?$6CiXhi~iaxIH(xj1%O3}$U!n3Ms=dOGo@h}B=tWVdmE=N zy%GF8aRlP33PCZTsUtv*@`!O!Iso=t=(Rsv12x2Qq9AVdps^lGJt#P#Q#mF zauXyY`{2?k2sHU2thM6MB0@y>!J$*Mxyi9y``LiF-W1DwhVFAt{sxN&2ye&!)lDF# znk_E~#~;NPUkloy#icmZaFB!>kO5Zw@f@#}C~i;MVy_Vcjw}Rc-;0??0G(u$!Czt$ z?l9yNJ0;FSLOKB`mo(38Y)$Kf4)b{QW=S;ddKyk82n$p()%z-)Ig*cG?7ctVl;;Li zo3P)N%$xIG?&eHSSaJY;z~NB9q*GNGmQan(-)62(I(GMk^z$$bEM&Iq)*ZT<`8%YQ zcpxG@Sah6T@84=6-_Oz4;?wsYx*&GsQLFmr+LzZWIh;aIhI!!T#ogc5ysRDyU4|MI zTS8Esfqu?-KMeP&_c#NQE^bF(<=`Fz$nX$0aTC!{}4*Ws2$!)l&P z5V>PPt)r)7=cP{Yp5|bNaxI`U-^;>t=g+Tw zj{9TRzQ)*QwZf3iHbeHCr4DDtIKYvnoF#cHFq4y)53EJYcshTZ{4Th1O|0#shuNX3 z&rX4)P~t{gOgMD*eUMc8`U%U8Fm^;gz%EQ*JwW_=llz2bPX~zcL~&*6p(lcGqEYIR z>$6pZ+SO7De_bU>?y?!__%tM?ko}JHkhe4ZnrMffawVJ6z}h#gu_S{ zDE)%9#yN7Xu>2K@w?CTqrLiQVyy^#wAc;1Z`g6nd7MJLAMNLEGvXQkop{R z3$~@gC$kHLHtJq&>k^krb&d}1{Gi9;9rFBGjyvg6JD{y&K4j4C+&K43e}->axNB(X zP9^`5h@#Sto6k|JiT4u(4{ul-eX>1Q=}BpO$IqP4*1b^lfAifpJf_s|O2UrbgF#D$Pj7tr9h1(>?uuj2 zi%`$oU3|lhL@~BLHY`?ENft=#wyVBJ#`bXDUzi z7mJwYvx$NYRyMwPe<@j>fAUxN?NUblB0%`)*^iO6w>RR7i9>6>xF}okYR6XRTRC%4lg28f@IY4PlTMTh529(JUQ^RhgCK2HflIF;SM=VqGWMno=-1LRv+r1w{ji<{;sf};h0 z=8p3VwDqkc&5YnkOj@KZD05z@nr257db4fh4t)=|#79Iu$$Bg*kvoIXQ2L}hu$NT0 zm;^P#?)6i?i-X9gmIFA#{y{T_Kro)5%*Y-|s%(_)k}veqsbV*4wf=-T(LT)gokAc5 zl$=>Gri9jabRE~;Kka3HyY?yq4Nx2vuP-dGgtz&k5b%$1JJ+O>?H7a=DpAkK@6|x@5o4+k_G;$lUCfFhYi^8`B_W5b`T3FBK`&pqzHXNv zST+aA%)g-tw}^94>B$c%NX%7SpEV^8ta;G1(5tR-x@V7rgv70RiR(PH-XIJo6C6%$ z26X3LdglPD6}~Bg^a>*QeCMa9zOti?yFbi`vv=LMC$zm~F=C|K`Mn9Qj`{RvxxaQ` zrG7ATG)F0uXw6M2n^^`$&9oSc7a!VRMI)`axFmw=NruU8^q;v%9~o&EWi8_?eXOw{S-U1>1p*n=Wdn8WcZ!5*t2aDG5+x zu>@WWiGL|8%JCHa{_qKKuER|atP8O|Fv&VlA60kd2*Oa>mC9|(2#eiI>eNxs)O z+fdDq2Gei=4s-&RY{P$OuYR5>Uk0R4>0bfP$gG)b3lCH9ooBt`-iTI}cgbC|%R$zw zrFIi?W+RnK?a=v)ys*93CZFWP3=erX>{)Jj$RJ3`G?sbjhD*+<0lB3RaVTy7SuDqk zOC%LuTXCv^**pI-dTN@bH$Vq?N#NP2RCCZs*b#VP1hjrV_a7B~S(~7@)9AA8a%00X zDpMe+nFaH{IPTR?J9ja*SL;sXP@06IAq={UL6(? z@%^>&GI<6)OZhW(1@Dx{vtVv=RRc6ODqiw%VJ zo&lhsP97p#BY=<*DQ-?ClYY7z6rTcvq* zfsL`GQ;9Zr@^3I(8vAuZW>QLe`m-^$;E_?0+~>RaXh?+o5=kcxLZEf{ty@!tW(0A% zPhO(i+lpvD`Gx(Mx%6~7LC!%da-i`=x&^-B4MHUYBS1IsBmRYk@#yl62y*W5nCruv z6mMwB+m>rlMrsmq8Dkmmze@D{DmnJ}O4)z*gv0XIku}K|vTUWz$;0B~_T#JSS!?BK zdJEBuocw29g!1^HxKvW1eeo*`qqyApOU)adfftg#9p8eB>Is!T&l#5&nYB-Sj4n%5 zh~^ESys6BMsAK)Xg5C_UQtz|~?ZAS;5a-wvqT?`A%t0f>?s}QcJZ0ica6tRix~X>Q zl>tqzlIQW;!K@O($QVGAE7u_CtorSqdqhk&&n-5qztuoc%q}?@S(30j&{)p9xB78u z{4mC^3E;Vl3mDdymN`C_v14m9ZI@-Ir?#<_xIIEFlM!nCF^=~tID;XO@*tfl-%I?& zPc{|19lf{DBjb?I31|9J7!cH<=tb7h|DM7fmc`3f3T?sus>R*#IkgRJu!jiM>m829 z!}h|02YEs!i>cl>(tb-S1PFgW(j;wSs=a=Q&<1olvNYn#WMkATZI%L@hBLf(S{$bu z3dooOthe5@=u$FsC?CYLfk6IgIR)3PPZkG+oh}M0ve(WdPz-(nF(SuUclEvxt6qWY zvra{j+tTi_KI?tkx)gc8njYC4pn<4h?moeoP(tQ?rrMk~itGOsd-&Z4jcJpmN#DJl zX$+;8$90NjpbC? zE-zA+SKCCMD!R+<+9_iEZ^ic(!@_MJ1`zM_r&`Y;=kn)T-{_-3lY`j}~E>>oKt7CkfmO zeP3{AtkkFs>8GdU*UP?uyV)ek=HaPnXCN&jy59a$SXgw23c?ep%zpOUtqVo5^U9I)HQEnjrt&B8@Sc{f3Y3^*V0Ak)xKR>3G$WpYuO#cbt(&V_xPV2y~ z0dj1H*C&I@L4HYJLYy1S{X7F4|J?68YrRQlH4f+LT8jITE{}|Cm}j&%{JIjdY%v2k zj9=sv!N_}`O!Z`nZgt8?NkYT;mZ&A#(>42WOUP{Q+?Hk7-AJ{7Uz8gCqxNnJk-(wD z41VSQFbJRRR?n%?lv}p`j)MqFcl>y+vO&{O&^)_z8E|Kyjn5<*Jn>`C`S!eL~Xlcs+x#8KQcB4jvHZN>O@^2m*oHzU8at@{kUQI2GuP%&mqm(TX2# z%XPyWl$)lB`4bM2t<&HIo5{0S%{8yJbkzLi(WkxiBIepu`h3E5~roeDC!)R|I5c+8D@3r7CCJZGs@2Ues+&h+P^eD?w>GqehIRk&8G(*~1Mx>1z9Cb6NhCnG3HT!>_2V*6%)9Gf@{0*#ceQ|0GLRgn-ZZV$ z!Kc@j5g^Jx8lBPv$1DNaL|e+T^|3)0(D$^VI+>h(6~9g6Tw5DnPi+T{qdEeSG|caG z(6Dddwvrew3%%7!{2b%M`n(Yd9E|4+kmQfzo@r6m%i_xs*C4xL**mT3oYx!|@P1HGK>y9;Q**nIZuq=9mfIs2 za*z2YO9K*e)z@b^=pXTvZnGack;{e{NphsMI-IaqlV1{ z>9)NN!>)fFG`4Q9pc-G4Z;X~?9`{qbCeX8+;UHO@ZrV)}(;AW-eJ_lP)(weLYL%$C z0KS%_bqwQE7I2{bSK>xw`6foag?``+;*b`r+H{_}Dt_q1d>Yq+U1S{=&Qy$kdQLd>s98)}>rFX;5!GEM)MJQ_XhkK;ejIU4}Pe(=vWR^6nOQ;*}nx88QEk4i^ ztmb#u_OHiI&&fMT>a+xmGx~%$5=1?M>NCd_L%3HOA6y&nY!S_wVu?<35DeEtLn$1h z;W3IzXrrermK6mVqvlb&i6X$;+6@F7il*9-kMv{Z9I5&vV`D;05AkJM6ahc75UD;B zi`u#Ww_J0y^976LQ2+`(z zE|dCuH9QVWX_xCv6pwiy0;4ep5u_+>?SO~&w%O~kZvUfC1rD9h?-g2B(7h|PY5ak4 z#`&atx>z4=GrcXSl_$RR(WNX1Mz0eb*VLsyHIe=s&k7biP4B-=1BgG|^TR1#Tz1{d zEFJ#KSm1+xfexVM0m<8RjsQwcfiWkT467(w?=aq-%tzsyMyaUd?zeaB3s_0vD-Tv> zpB7INzzQ=fB@AHOCJvV|QP+C;yhIjn@l$w34#?p+_m&2|u0~Ur_1Qz>^eLoq znb=i3#Rle8O>yYD{g{(mtvBlQ**ec{+Aa**kN1OU=W84!?bVD%@3txoZV*f`Kmb&k z`ZNc?_mVGs^r7X54wKgxR9U8yBp6n<2y&rUtxs8mH?XFE4TlQ{=<|#(m?`7M_CMDJ z;0tugKqT@?nnMJKt?b$TxG ziw8sPlxOy?3Xv`eMHVv!>YoQ^IS|9uo8M>3eOUX}lFJRGfoXy_;InHI19;u(IrxJQ zsYbR#N($0^zq&*&Snptr&8~oaCbZ_&X{7t-Toqnx$d6~94F?<{apa6gr~H)64ny2s zM_u{EHbWbjzV%ad1O+9%aNCmlXUJ<1!081&za-P)-8&%FZUC+A&$W#0Y-cIx95SVwAqa1nc@nG9W_k?M zX{I1-I6`VmkFVxmDdtV<@~9u2;yECDh5+_7x{5o&9SBFhZQbL^NOo&(_QKz7*93HU zpIRpiCl3?45 zZ+NyWKvV>P&{$e(e3#2o8T0ri81J898(sq^6zU0xepg8P!Mweg09~6In0^W(*3c$g z9#@L=07`gFe{n(fE$&n`$LUcI5MEkc^b!nsAC`kmgmns3(mLlk|1-!YOt;u&hD!TK z(9Bj!7cZ5lidB^Ie-R?l3kq~Rc7EZJ^NlV*_TefhT-yjnK>WQ%M+Lv|-P(-=%Bk7h zaPswyJ;#d=So(n53h-CvYp3?1AIG^OA01axb=C*?^KlcS;f5tcZPa4jUn&=Kj_=w| zrIvuxn52?9>J)2QWY8-G0mosPVV0UR)Fj#)@6Z2zA=X$nwNpT*nry@Qhw&^v#&>!e zz;eA0xh^pJ`k4JVzFcWrfBkA04FMJo%xAp-L~N85Yck#^lV+xEa*6;Z>n)?tYb{o1 zXgD?E{uBL^fT~=hycKyRAG%q`&msSOAcUHBz5JFu^56uue2D+3g=(dVtLjE_7IxT8 zW7Na0SADAKt*#E%9|)JsOMglrh(J7eiMUEK^Ykd8IrETl_JeMO*(I^mQq%7OFK{<% z6LB^UxVYI3tYA2*@_1w$_=+kH57FZ?1@b1(l5^J(A%MHu6~ERJ$)fvbm3tMnnU?@g z%R4&Px(G>VKb_`Gs_5!%CQ0)|vtd|?Cwp*D@ahGK)tXi4`YnsNDYs94DMAoxo=2F9 z&abg6x>67g%JJ75hku$N7Vr)P_UX5mbYF&v5Y1V(wK)_E|e}hYla^xG`Zj*<#}K_z&^I}8+$)i zJ>GZX9=ZHGo3DcsV$UWkhX4Sexqg^Ek5_7?=&{IodUFEAJIoO2bl{Re<;O_{qHfPF z14&XPD=%Qsarj0ZDIf!cz4ZuDGF_nk1mP1xqi@tx$i4w_DBvnpO*Qe9)?g#{!LcTQ zJ0?>&GU0i_A3hwRqsVbgTEVnbHL0NSNVE9knBZPt(kVa~-17~9_P+-Tq>nds3;#M6 zs7FOB@I0#O1qgb6zVVof-_WG9NZ(IO0pIZYUH({dCSn)#0V8&HmgJoyj&xoX+YgKc zd!T^;B37;f41dGJr+R<<%_e|zszh2~@rFig4_?1UsDZo3@WwrU+IAa$EL=bV{nP8uhrn!4}irn*JK|tG*e_4|@BXtRBW>6+vNlFv$o=@vMcji&#A#yTekj**553_&9- zE}zR?ZYVuRFF?I`xo(vz?C$wdPcy*fqV9cNavE}vD(UbrxSa&(Z!E`W6*-LWC;(Mk zHUtleCFP_3YukRKW5W&9X}gpw%obMTGqA{zG5orPF~tiYJL$XBjg&fdh8J0PW!qaS zrfYDuqu~){d(jWV{3d=6m~h@ecNQvoM#t7!cmz>SufFQt`zx)PRH%IMNpf>bkO(wF zc>AK_9v}Fs&7a1KH`;(7=LF-2pTSZ2@ zfDSRRn{!em#rSX}8*BVLqJ~RxR&i*#_`>F&%{IhZXWl-Q{TLhF-(!%$tsPxss^nZE zwy+-X_NYIm$t2;^?)5)q0|-A{6VL!6&KOWSqfDFN<}H6~NX~tWIxNn$muYuagWch> zY2|&6jQa+M4rI&7TG9;7kdNueVlL4k=SqV3Efy;cCdw>6!D}F>^7{%T`>yIB^{jf)*9 z_IKiE)H<8Q+Oej0-YP8VToaTejgL4<~s8m{T9thbt;M!TE42=d{6e~?6eIYNLwFm9c)CMeJ{3W zrZ@TRb+loJ9AeF`ONloQeMkI2*?Ld{WxM)Q>H^f2<*n&%4F6zPgJ)ud9ad^)C_COI zLJ$ilBeUsWh-|t5V2ny{N{+(|Z2i>a(m`^{|s1V%G&oIsY zql1m7i|~jCjTbVnNg;t>%mAILmHqp_f_h!YoYBXvJfI_f`}IrZ!G?b?5Mi@sI0CO7v$DBT%<84Nb<~*dX(hOI5>^gFT#2F1RQxoL$i)))HT)H*VO`vt{?P9 zo&fO^X^RSYGrzwTPyX>I*yY+__`Dk^%OU}>TV^N*n$#nPjb zziQ9uck1iIFRs)-MC6l4_fOu#S^NiL4fDoG4{x@vDf(D@U5&>bdV;HZ_ozzd8hs_) zgrb34MY`9fPUR``h!_O};jP?BCUBjz%+p3Nk_hSh zWF-Zvj@VDS=Pe-dfQ*-<(h9i%79nJ59NPS60WNW3E7u3k(?6CG0922EJC-na_~4gl zG4qyjUt*-8O}9F&5hCrUEg<`{*toRBvMSa;aGdn7lg((ft?s*oQ;5u6FQhP&ViAF+ z%x4neKYC=dt|Y?|5NU4KDGq)9{p}?B1XpT@$y+#rkST{(`~kJit=anF7rOLNcCB}n zH@WhAUYo%X@}GW`--GWC0RkkQ$Qqt!w!=-E<)5UtYO02V-XD+*#K7?qU-DL|gXf}Z zTnWA!B}`2=zk0u!H|%IQ5^tIdtW+?g7t5r*_;JY#5J7}}3gFhs8W%1?9;s!bvsq#`;w^49NZ{FAN#)1J8nmSvd82G`6#|t9o~}m>|mP&o$(gL)3Nz+*84I zB+#w)yGQ%aNO(j7MRZ_PZ{eF^5hfW$s}sg^al@!X ztn&ZDB5}WfcGq@BI;FW6O#;CF2Vl_LMLd?LJ@9vvJS8)$70d-f8}>MX{!Y@p$Q-JRf1=m{HceyJ%PMZGgV?Lc|&@i{xwD>!O;gg2vN1tiyk21gR zlUG4K=*ZX@2!B2lQp|n>G-lPv(fc^0l&~A16f{e%9lpDbjkDd@n_1_rn$17b9{JxG zP@yEL-?+p8!cf1Xi}sJaBBP}8P#`Uzz^^P2Gy7I7duaND$$OH|H!ehU?T|73>wtyG z1Dw=4mi;o&w|n9?oY1l)(D!{$KMlLy5+FsU(&sgRK_C{HgboD-nu;Yhptkn+5*y^# zf9cU^3FnYVlV}<9Z;w+m$vUv~W18RoOPZSSj-sT=s8#Uho?8ak64T?nr&%lb!1Uhu zfOf-!_q<17RAo~{ronw{Crz{Zv5vg1_oz7V&eL9i&Bg9#Tw1>d;kkaAyS+W(U>ea> zk)Gr%ddw4km%)K>q7Bv@^}Q~{efrz)^?jytH{a2Kf^NP`!H0mZGk%bQl>1lrRoloZ zF|BevZdR^X4&k18&r^Tmt(&xsH`f7#ms~_G0|D|BCe!~`w^Rdl(sEx`SSPq{mo;0+ z-DjsTRbQ4(I?7+jcUwv}mu7wG_q4W?wX>F@IibHpL@ut8lYOiViU!Mi&6 zj__M-9lek)y-;QO|E#1#_`e?n!0Jz6^$@hfs9t2tY$3Og=V3WlN)zPApvQHpvs!Jj z0>1V035>fYElK8v>n4*J%M+IHMbDWn#-9E*Eb+bGXyu$9Zh)svaChFP?Jk8dC|oY4Zl za>0G!Wrup>#oN8sC4S#|?IV~|AHmcg@{q`V7fMe`g>gQhp*%tg$|%5gC0Ch}E>6ae zfj`*Vih^Z`4|8D&1#F+Q8fL;vGyVL{+a8j6_ChsgD3045^3Iy|EtgyDKl^keI*9>y z>siExb_WX2Q0>#+%5t4~l{-x}1ES0%7aus+qUwHJElicGJrgwRZi(>*FaqH^y|GQg6UlY5-|7F_`y^ zW^s`be4|y3eE{K`;H?k5vl;Q#+^Og0MiW*j6AU~3JvP2p7B^k-8k1IAu%V_Xr)pRaip~{^74qH=PquQt_ z$Yk&8S0+2qeROxyyZM@tSzpp5 z4Rh@X{A4kol~YQr-O3t7jmR@jtKvYZg<5@IF--XjD54GiiRsxkrQRHVv9TFd#bEZ_ zzk8Gaf1Qvk+u^$;fE~WG#KTv`kIvArMcb-hD8E#4Rl;bB0|pZAgFAez3m?7NEsc;# z9w0N$n}MxAfiF(Nih!_>#Zh~Qj~ zOrqP0vA7JZ&`UkbNaS6qq`$hFlW1%F!bF|#PFm;kNA$JC97kj9q6_URICJqAovYB2 z;PjA?N8GH3VjX+qZuePQ{R&_YQGLTFGDY50$^#lzH=4=82LU2$2YhZ;?+$|)g zcBFlPy3=?Q1VobQX^EJ%U}DyD;X>PC?H@s)Jr(Q#_Z-8v7tccr25#KZ!nKJcn+_IM zSKa;VCv>i;_R2O(448dDXb&5k?F;{p{dF6uSV(x}VawUStWN)Agu#0NTdeU1Urs3?Q}`p@SVZ@K^Iu|Wr^+rmlUz@NiO0$S8b`jo~dF-Q6E zwr);Bq*UO;5`H$Y+!P}~0@nbDSBLpOxj#-!&0x$f(Tns@_J?MWguDY_{vqqpnXn5s zglhvteD{&o0^@M5_;r%gW(Vv^8fT`(aEU-KKKQmM@%LDK@s;8hAfl6fW->X)o>s>5 zg=-PLsik4d3;U{wH8?T-yxc@1!sYk-{=@G0;B?{XW(bMIDR4gJ)#NYBWu2HXf0uG% zXE~B&ZGHrn<~+`nf-)SG#_L%$>TRCBzn#2w8y*(}!y=DAsZ8XwG3~3cihPw`pyG(_ z4}DlduikuZs4HnIL<6j(WJ^vW)~SXZhXW80IXIi{bdpVaZh$eU-k`Y~J(OxVC{`zV zlOINqm^I?@StIKQmsY&CODyp7I!ke=0Xfc!UQ8^1tj2KgoAcV5W1Jrpxm)RA^fx$6 zaZsVZ8j#&;%)<+Bp2GG4Z>V^FHtyWkN%SsFD zzZV$c29H!slZc|OL^L3yU^p}YOG*xfmo3(R-C|1QG^U*ERMuO=!Mbq|zx@J`zI~sP z3q|3$J~3+hNobxuXeDtKvbHTmoBEVM{d7P>Yrr2OJ&*#d1}6E^ygwZB2N&lmfn#4- zn0@e-JxiVWo_i?C+w@6K6@IXf6+hC+hm-kNYa=Z##aqoZ7hG{YvY9>Vx-ag>H)M!j z0;IYBXEH_|&2Hp&x%dSTXM7fZJm#gVtz%RrQKg^`LwJjE#jmd$?jz7?;-EY&offL+ zpJ`0Kj?MN}VC33gv*(|Xx(^j-r0^*EVT6JlmG}|gWA@__hWAT^Py>B^_gzWT+Wv0h)VvbQt`m$>lJuEK!4%Hb`$U&ubYawS&Wx$g2dI^aE^qe z#gl^60@!$oj#$k*9BgUZ7*;Y{A;4{NKCZ@%rev9TWx5V)kbVLt=j^H5xS3GY{s65w zC6`$H&#fzd^$ODG*fn3j0r^s*$Jn>S$?Y>%>HTi6iE_f?^2Kd>XtN*95$k~5m5;Ef zT^UIT7e`{)a)MxP4~oLA%7;eE%JypFtJwa-xw5jGdhDq8f;nk^Ly3RAm3-6XH@Db7 zTvV-5MUnjd^)+o$39(1>=K9eOl)i^jankmRC-}-BBS3Ckkvj|UVy$u zn+fQI)+=+LMS%`)T`gc3gFteF5V#d3o{W&jkAym)=yPF59(jTD*&ihM^!SR3iv|JV z9Jys(j5(Eq(x}G5x^pl4c{pmX1IZUm_?-OJlT$-ZeGZS#K!;3dlU014EqFx*uX4FE zYPQ6up~9=3*A#k5o4Ssrt(Dr7b!T4zt*on`x{(B|<|ufP_QLLE`Y*w0XMi+tv0Z$_ z-wD$-ieDpt+Xb<83Yr+H4r!*h@m$R2dn)4NwLcno5geA#r+g7|q^7}-6pCssz&+-* z5;3jWgRfEDEscSVaq?6kdpP&m-ig2Fo*?y}EPKk`!sdu04#>^B>^wHQJlPLX*-%<3 zxA*22ySI9D)wUq*0Z~IwhwB@7EMYdAi|$*oiBrw~NCFaM;I;#=UKVhS<>;dayTU9& zs2JbkM2J8xGvgOB2uLVENT$mkGk*mtDoMFN$)&8*6RJaVg8yBVd1M3nWj108QdNF# z(NyeJxP-LUBXHgzTLNegy79cx+@HU3Ml}UkV^5CuN<_K87GqINq7-PBrTF`T&UI$b zZ}Bb;|DwVBgukRGNQFZ1waVYWN;(Epo8Z1PY*G7Xd%&K~CMn0=6u&F^h!h%zkDgk7 zVs13?1rvsR!VCUGO}tw}N#CY>&#mBW5Wne{I`- z)rS`|3Q!?vsfszKwVB3Yhw6l@Y`^%|IZ}UX)Xmv4JV+;-#9I-8Q3OH6@`2+5N5F(L zcYN-f)pI;*znif49EZ545Y9L$C_^vWW%9N(jV>z-*azB8+EmQ!;RE{`3T%DCr?Raz z3nyd)eM4zBunKkg;jLzQs)>lyvl#Nd3MB^K_)#Ki3;b{Q9J*w&5%riV~ifvRZO{w4_h-q*{Dm(kg=->9-B} zL$`P4SqU!PF$ep|j5|a)(UuHteGl5gSO;&TPbUOlpR{jN|KTk@JosTzN1?tpCYwc2 z0x2wnSg5xL%v2oqiZ8{xXORkVufd`{Cfcvl?Sm9hAM~297?J7YW}fSXPFE^Wloh|c zUENQ=JG@yJ20y+vA1c1y(VXY9%~%{&M)61(yXAvBo&~~N$ueVH0te8H#GhPM zrrO(t=PxRz=nPeq=VUB)yJ04@{7x-Gdf#gjJj}lob8KvxNayUT|XL>VWV-0kG>;tHE(`H6^Fsz-z3U#c1#?*eMP=D zpvIX(C4_!|S>oYFhXICMMH$U6(8{B`6tZ3ek<%5>!rh=5+Q$6_SApTW~MtXzt!i1k{lQw}^=?k`H?kA0&3%Eklo7Qb8Pf7RSJ$QAJgmN5SB_|5$HK~gG-I)9> zsiD)}x+iXewvNpTugQ(XZ3Y4EP-e-F(+~Z0q4IdNV!`shP`KPh_D0_GK#8BB?C(}>SSZ{o zpJZG*65sRfxIV>OF~yH1s8KS82rXY9s7{wGH{Il!5Oa{2@BJM9cJib9s*}B&}GF($C*0Opp2XrC80UaC*gjc{H+)d3DOxFymTJk%Q7Yj5sjEBM&=ZFE) z85}%3Xf^1W;G{tjHF&@D;j1?>7yWoi20Kk<^%C;SOcxhA3}UO_UV=V#OqS!{h!7Cc z1k+&rLM+LKBOk%}n|2vCL@;6$*O$(WKlDMiKFO$lg)usuI?F7#;1jw?_rY_7xHq_0 zrKLq(b@XtInO)%^Zp>*cHZ<~t;RmDj_IM(;C1BC}$uXE*=^I=At?b1)@ctamJt?_; z>?Hyfm)SKi1~>Wvuh5JRa+@P(1Uc;}JgAK_YXN=h)mX4}n%8N6ROUJIfu`kEpm zr8l}#Am;^}4X|vXB*E|)UId=P?~YmRz2c+K=P-nVv^3_aAL97m>`7P|TXU&txQUbA zzfKF(pQu2*I`JqJ&1E9f5ctXOROS&d{Y6g?{O*%p2%75<+zA?H zlRExH%^SxQ7M2a>yI|u!`1?cyD?_xxNoW@Z`??Zn|&=Vde@JSr5{nPfb zVTA#0QzqaT?tJ=zBgnPHWQB?CYo(MU&1On0qqyoY5CDlPI}!`{qG#xAEOhYxL#>u< z1AsJSWZ0-FV_HZN2!tr_ldq8IXYshb#9bYob?iFl9#u5YS7K8!|q`tWGcd0`o#e{LY@^{-K`j zxKCvsGhPkGM|yg<$YIiqE=qwS117 zhN9fPD=x(tHtoR{K18Cm#XMc%-;m*FrAp8&t2)H^-Tf#)VK6cQFZfs2Sg=}P-2d8H6*|0aK{ z9W0D=@|h6^;^jbJlub5F)M4l&-n3*8Nq*Q^?91^Kos!eL8=#hBDfZYJ{<# zC4@{qzWjoWdrF{K0A^IY$_pdJQXGYMj@$@b0HAHx>!?zDG7kTwmj57SucVJuLi9^dF%hZ4 zC(pr}`&{ohGq#a050Of`3tzZo&6*4o$DfM<+%&(T*BP&RLxz%=gq-nNpg`CnmvvBo z#XXsJYpAvoYOUQ+Q&C8=K>9`?ip=S1@4V_vku5lkZ%?}*ekcMvC=>3qi5Sp}X<%u| z*WOlD8hIo_Tmwf2YpxAyrC8T!RWhBt3h}EH)}M!0joO(h{8>e5h7Rr4&di{;O$$<6Ol;ae{9>ZMAGUq)O9g)r1ai5EI5YSq|02M70< zZ2gzNaIbpvO9XyokiTERDy$`O>MVN{;b;>sGadOE`X}nlBkv_EyQ}A<;yrj2wHb;< zeNwc%LPUvyKDrNIe^--?n7z2-zbwAIkGr35=zqUHv^w#7%Rx2DcA-9xjzPOfS(T#` z9{&K+Sh{%f+bMKigJ_04JW$%W_V1!Zh}8r=iAx!?l(a zp7^bCr2YyB!O?GQ)OIwJnyOXHZ!4eUm}xXFDjY3LFf}k|G&FryE-|ttMhpe;sw^;o zQfK0RvJUYBUNdZ^Oj%Fo$EJ=a@DLuZTkmVfrh#=Re%JLday4}{473ri$Cd8&v~u2> zUKG&GCRkx=W_*dpsB;Pr@78Q8!}0ua@i z0|Nh$6X0Jd9#ov6#((@|+`a{%E?*zK(@SO@00z;M&Q98uDpAG?k4Tc22B^MYmUTFz zuD}5HIu+_rh2_?gEmHf7ohos3j`cTlwXu+)@4+pb#|(C-50_-GQS8~cZ;ckq#&<(!{Ra}mUbAoVJIe;WY`kS;jjhHaSPYV-e{1AC8G1j=6dy?R z;t3t>4@G2Mf=I!jTKJT=LbEVe8FGekhg(DDJ>QKHJ_yI2oGxGkT)0 z*m}kM4mK|C*yh|FFHi`aQJh5(>Vm>76Ug$JX=%@8i-fuPKq!1m=ism7#Gm-L`NlEZ zhuTIdE2~Y=22GR*8VnaI-5|a+ScgW-?MV3p6M=kkHM8FXabiKK(QfE;U==*%QtP^F zI>CRks2l)V1H4+~7XuUz;Kdx>VB^E(+FQuyin}bHsoemcxt(>IjWuVVz^4%% zZw$U+DGO{cU%y?cV=+YPyDnMAw$}ucsUK=e+R0O*WS-bPZ>=K2LlF(G0$6d+Pdo%` z#OgKk7Yq2hr*9!m=2?)_i622@h1(`Hp9Qd=G%U6-MIm&>ka zL9EkCzA9;+D|&MBTnR3CYf^c@dR1Z-QRo1@b?p_GssrmwdUPwI_>74^4M z{>iPtV2}7USNIdt5rSvegN}`_b0_&dnkl&CeCh279&^ zb$s?yzFrr#kA(IgiC~jDIFINI4dX1J|02)P+6!2}>54{35*0w{v5h4+R43_s0PiL- z7MOEC$AuUnIx9G{KLVr6lF{uJ{Y7a$Q4Uf#fyOM(U04`W{pyZTrzm1`=_;w}rBO;Z z#60=-_ddj+lcSl!yDj;rf&TG%xrqoBH!*DzvRxp^uQs>3-mJPb@;c!6d_Z2VwaQVR ziWELpXwen8qfzOMXL`?VH8FbUzDc(uf&_Xzto`u$rBTqCT2@mt8UUmO{G2YLKUw)2 z#|-LOIA7r$js^k$ttRj`0E33n^*upoFIp#dFDK^}Sho-ca^tvGs-211h5wc}zr;n_ zf5-Mw(iu1Sz?)_$nNMhst!XvQg?x*=(>W+Z#=*D7rfsDI{t0(?sA7Csm$LsUT!LlU zpOk&ik8Xk~l(G{G7 zf@Z?MHg7tK4nBYYv=1?p6oo}sZb3l~`9K642ilXbjC6*<)M+}9~iDk{H(#c^a z^p&-}TkDt)UmfByp+}~}=t?*9Q%%)F<;+*C-^e4ukx9H-S7j<6QkBsLE8YbtvUoV_ zocIBVy1F#_1RhACtw33{F~F(~?^Qy4lO8Wl);6D#QsU1!-XB)*on*l8{kJ%l*9~`C zC!)Y4SKq!EkRv?AAbEz)56{I?#G#qKHK6KW(n9=|FN+=I&~76WD1|rIef*h`(Ds&@ zHnG%ybQU#2auN2t^gKa8y#5R!$?Gf>5hN!S%*Trrp9!?PHX?OVY=O_IcM~A#Mq_a2tp`7WLk@+|52vh!=CKh=J57Hiz ze@WFY+qI6*#kk)AdxMoimw#zoE}eO3tSpO6j(NpsAf7?yDM&~RKCmh3{9M-_h*xzw z@i1}&SbHZ6ZLmM17ai_1ob5~KTNXD>w8$1I*HV-RrKxBF;4 zl`GM#z7cy(vOB-E*10BckXXDb{Kh_dHvoeU5G#_m)n}&0A=|4wc(=n~u2>h&vZH0Z%-5X~|a#Oa*&2hSU#Cc45(QWaI`}V*#LBiN=6q z2K-t1Fd`?vt>vz}ksrJCpaM)8Kan%Ydvl=r8wL^uAD7x&k)^anR7eeoF-X{d`-LGmCLY*=^D70n z`d5Oo;x*^ys!iqC{oXc!ozavK?xo-H)?zP~D~zB0G-^OVPyD@jcDr)M0-hHYBAUb` z9t!l=)#1!xNAd?dQPo^`by@y=NVm-QVU)v6qyZjZ0e2}TLGeqp5YqDD985-06t9ko zVPivy2e1ggw~HocPQDncHV?dCwIH6EG2YYD*~egsU&#~Pg+!m?1rE z(Uk=_Zbqk5(Itzm2ZR-GFQVjeZ5k(qGYMs>0AF^Jn}hiR3M$jTfgxEgdQ{+ z_!7y$KmRVEah9Qg-3WNIrLdSNB^nvIdOK@}$`0{jYFFaZ(=6(5*a21+sZ_hhAT2lI zc*cfFVxw0ZMMgraC`j@s^bQko`oU7xtOOV*?xp%*pm3cKmVjsAj9WVyisezzvRY(av1uRAQ58gC(bcMpH@&}2fgnU*>=;8k|W zCbWZr|Fs!IRE(K#`*|cS^vR+Gk6=KC{0x&=llnq@03TmGOSHqU{e<{BQ9Wa%MXHH? zpr5ttQ6M4?1gJa#@%Itd@yo}lSvMcXx>RLErUM1xvWVx;_`0xOa^yzirY}!TAF*jx zO>a|}+9!c27#XS<&tZwUxEqnBP`4(H7s`_gH%eqA)>=JRSJ|q0LRVQkRq=HGD4sb5 zCdsjWx_I_S;8K5+r}Y9YU}0sP;0+vI5H5KYK*>UJi1{ujEbZt5P0{_$5{mEa9)jXn z;P0Z1cc`tY>s`m;3$u`jPY=X9Z~-;KOHKJu#rSDI!Mcvrto7Z+LuCLKdZg%H2vF3m z-}BMm899dD*CU?Tz=ksusU7(c{OTOjhhVX2r^_b7NAFCcyfh<@%II3#nzR|Om*}| z;GbZVz~@;w;cKl)Zfj?qmw3AFh^?R)#@L|e;2Ehvl(9SNwK|*iX@wu{kgg2BOd`_3 z=<`40V|zLwjsP&RS3 zX{Z$<_w6G5>M{Gjh$nev4*(s(9%*rI;-7%{YZP8Zoh(QTJ}4H|=3TaV6!nOU<$<5o z8jFm@^^JRz@;IruWp&*`D)50Ft$RjBq zRd6(WClO}b{!}+Dv9yV@_a(YXFAFBlZioc9Arj#SJ{whwDq{N;MVDb;`wVYsmd*~P zg300i>?dAvVc%{c0Nm9}wcZVLxkyM8Aktw3bH3++qnGonzUsaJCfpXv{gynOv5~iq@vE;o++4o`Wq-z5_o2+} z_6wTSk|FTi>wG#$@e)6N-%;z#CjvcLt6QE~d;Wf1I3Z9wYWSt#AK_$2-a$$_h z4k&FenTd4m>;$J=0EOS_`D@-a1KKlwF$~~0ktx;8!`4!d;G*9be!I|%FCVxGTC$MD z()jXk%JGKXKV|fH`6HIIktNxf)y z_py|ZGK-Uh3>s`)OoamvAUt!(xA$S*^`@)_dHRq*GHpwxTCF(Si-ZUCgFgrV`UioS zDUvDgO}~|Fz|+LnAJ2@Gv%L`*%7MPWVf<(nTo2&&Ye&9vGmgl(i(3d_Yn6%Pp?wmDA<{8277WL`?8&nH|jy#Sg4zA8JnEj=%tF5!00% zLx|xGN7tmgsb{4BXqz&GAk0?3TlQSN*kQ@ZN>Gm23_G0%!1V4(R&v^@YWgZ|STLcF zE?D}XXaYs87JG}kht8_G8C;C+FoX1L&*s=Fuy~=Xp>uxotVX=_*u}N$`4McV9I*=* zs1_ZUnL+263$~2()aTK3i2wC~wohEk zQ+Bl(%0=nMr>$uV=ve{mSLZw~N|jLNUkiIPm0FKS@lY_En`cD~rAX1K-(P&867Z@3 zkwC!<+ICI4ZEpq%F&oYzM<}om)cU=(e{h;j2?U>U=}baD*oI9_zOGxG=?6JM#mwsY zN7)^x40y8>5J0H|?L4o|Wj86BD_wUkaeKk_|4||XH%*6?* z4ri$rkS70IQOOg=lHaBJ>)kHdgZlRhDTVU{ai0XhF<4^D?TJ||U{v+B6d=zg3>_?u zA)J7M%o!bl>9yD$H$7*%`(2254HDzu;6w`B2GD(C|I|1Xlph3;Qe8@30`4m%O6oE0 zq=ywkHVk`XB^{+C39WAF@p2H9d2}O%0!`0~3=<{cUQT1vWJ1pN3lXcqHk9lB3qD2Q0PW9L`$rfR z{sBGWOiCEUq6P|ViepNSY$iQ1ZBQ)d<54$SA)oX5xt|0dy9l)stujc7A2y2Ha=x-`3Mhfp-C{=yb@zC3Ryr^^~ik1ewyZWAKi|{PrWe z@@4MzxScr2MEcKnG%W;kUL=h6&Le+~t3hvE-)~vV2O1JU(=z*7nH{MewQb3^f$$^3 z#AuYy*qIvA6{ACWO}s-%2}gUd8#Mhpf6!fpquO{l57&DyL4TWbRy{1&Z>ssqT4Zj6 z-l+SkRanW^=dsIgSQ$C?P{wnq62f&DmkxjKXaaTuTV(QOfMpbdZpv&{P*(Y9&}OYe zVmo9IC*rR)3uH~xKgZ`%IDU21_y|_t-h$Vg%D@QY>ececB}-e9C9AUMce4FYy9OCY z23dNPJPV+D;NGtW2s$FEmfq4N4+L7Cc%-S*Q0pJi13fZ0!&-(w`X->mDh_cDR;(}l z{E}TGXUE$CkDnVQq>efFu3>?9?HQn5b*;WnD@9Xd94==0x+h;giXg-Lh3eumniwD7 zrQwP{acI-F0bs5IdeOnb)yVP2t&?F!q(cRh;l%M}CcmLkv=11QT z1h+CLeVn9)`9g^M9-^L*>&xK>un_2=$XXZ}2aBqiJsWc`M{d^*J*A&b(B|wJ%Ur8$ z^u}!ctk!<%S97i$GEs+dVY@tUT(Zd32+8C6(n$&-f_Tq8v~6WiuaYvR0mUz0{R4^4 z9)Pxw&!4K}hb%3#P5$!7ueM(g6hRj#06JHH57Xx|9T3;usp5UYx z%~R8q@XEo%XZ;(V^TmAx#A%nVt)~%NA0a6BLEfKGG15o<9c6B;xsgzm(-Jf(BHk7E zg#TR(y(4#Mf8z3RKioMzMnZsz`@_HS zb-tk!pzqE6E<0^Rc$Bx!=H=-8{L)Ss=_QGJ(<{F4aSMw^LE5&nM;2hwF(Z+K(Jwx1 z_jaFg1E+m;OD2?>{$4=qyGC;@AK?UOQ)MK#jl^7%!G@0kzl@4J|B$IJdgvVegt2I2 zujVcVW!lJJCL}2Wl#O;|-GiN>3NZv@UNNi}Cmv%;@_Pk+)rp61Gy(te;TNX=4 z=&VjUS3bg(B1dcFHFwyaIYo_C(7l>N*;g$#>F}Y|;6^m$Ry5}WcyNrU(Z#**+C_E) zx^H1RiB9cQOd;U@U z7@bFde2!~bM$wGybnLzud*gonw@NsQ%;iB*{?B<0@6w3}Amk*6wXJEkmH4>Q1HsP` zIAMvES(@_la30J)=&$i#Ly^}m@1AIZCmGs=vE=VD7g+HKevIYs_iT~LuzXKgHDo9} z1m`-FaX6KZww!2KlZN-FQa2OZQz>R`Q<)RL7`T(TK9_brJs8$HUOBy?;dy#c(C$TF?$h6J!_J|%t>}^aMyh`E4r4byxVTFPK0uMpa zITHht2FXMI%b7pXStti>zFPhG&C%YaC?j-5v?HGM%5PCz{SXEMX5tCJZ?(~3{9BxS za(oWtJXW_l5t$vI4aAc`bOE!Nh1fK0&v9Su+DkgUIBvVt*ItUg=`pr>sBed=zO|yQUEF!o4f_N|@=Fj2XyMTMEj>&HtL=v&P3%>p#zyMWT)QuvXf7T&41?cDS4%ZPsM zXui5%1%$YwnGsk?z z!pHh%)09Me$cy2#Mm#M_6$*?m!_Q6&;d6$Q+{UKj{d>ld{hJDJQJa9Vfgi^_@c0Nb z=Gspm^S4B}1@4rFW)=g$Qm0lu;vOlXe%^yaln|F8uC_#N&rkNjN6K!tLvTW0t#4`d zgSU`vDE_d7|D3_Y?YHsY&pxNfFWz57aYg}2f~iDyl?I`o$@R_N@bEVyhfIdPbZXV0 zI9GM*&vWifk;Hx;J#`&k^64p*?ZO3l>{T0J~dK$JZ=;+QRDPF3mf1 zpo|4Wu+onk81~8l20PAGg!#h$=1@TKCcqTACbcwp`?I{`@keknZ^w?0Zg#hOYu>}0 zUEN(9k!65z4vgJGykZKq`G>Jh(|bp#f4qVQj>RcFf)5_@j&@6sBEewh2{)gjhVu#|Y-A9A`z1#%l3U>T$t8Ge)&|Lop+UUZUO+--VIeoaH{#s>UTk zm{jc|0bs_H{*EIDKbWq+6alkgwT%39E7<5YkWUcz&Takr7`=Mi8xM`D9eb)|oR3Vn zr%`a4Ze4oex(=l`zxeik!azZXgCmFX9(c_>pgj^3A zBXT)I!wjOwxP*{PN(>?6emNu&#w|5e7`jYMF~)Ti#!O?LZ#~a{@vODi{$;PV*V^Cx z-JiWbulH-OuOl9OhSiF%*lIm9esMPFmwB)VpE%QyvVs#FtaZ&}sgHRMuKNz2y={q;DH=@y{y*{1z z)K&Vkl7=xzu7fP-vwPfoDh1lL`K#%ir~^BY?6#IT+P=2a{HH5IIguH%SxMuOQ9`K1 zvd~2`QB6gls3Q)@(-=k;Bw%#Hn2QYU4#_{a!4+Sr|rv6TZ z4d=dSU(Mj6yr|Oy%z10ErVY6gF_mnhA%(c zW!K4vP9lLDl3&S zo@hcO0z_?NaUT_#b|5GRllDPzu-&k~E%{2}^M;D+xM`=sdPJ@7G<)UN!VcuPZ){q7 z*T0Hv!Y*S!01U|<-^$zBO6SO2A~9Xl8=dcS=jRfa5+PORJbvbhBp-Dzs$TMuzOG`C zN9olsFuy$eNMs8@$B;!F8Gf~O%BHpf&BIWPX0q}Oe}e7(h$?B z_^hHx=o^q2Iwkq^7?r~9eq9YbyTe8+j4UTF&4{R!f>)RE-n^JY*cSZRU{WqO3Ps8| zmct#z<7{?60j+-w+~6=AMRG(_u9F?t<(1fI2e(qF($BlFLh2tkP zWG&sd4-o>wO+t^nsLKpc-PEhK;cz4nNJ2^9K-BO4z;~EM3iX0s81FWES>cpb&F!;6U5dyMFc|*PXcNeZ z{N?ty_gjiiHZeP~ew)@pYiZeJ>6v{6?3pW}mzUbR1FqiI+a*TTMH|6rn;r92s)LcY z;3E34;!Cqf*1tBXLS-|#>nRh(y!=wZo6Rr4FqnSJOYd*$N-JaJ6<%<7MlT$bYx#1nJ{CIYXfHP{K9Vm<{8saNl zoMjRa$ZJl;#+_#G#lk~vO7_YCjv(d2d|A`a#O&bpWmMlZD)Yq!AzHFw<8QaF7QWSnuyw}Q|aqx)(?=wE^tWd%yD<8Bb=3vX$ zB;IrWzP;UA221O4dEDy283KXZI}o@%bXWfQKmZLjLBoDZfcH~ xo=I literal 0 HcmV?d00001 diff --git a/src/services/whiteLabel.ts b/src/services/whiteLabel.ts index 8c1f56d..e53ef6a 100644 --- a/src/services/whiteLabel.ts +++ b/src/services/whiteLabel.ts @@ -12,6 +12,8 @@ import MiVentLightLogo from "../assets/whitelabels/MiVent/lightLogo.png"; // import OmniAirLogo from "../assets/whitelabels/OmniAir/OmniAirLogo.png"; import MiPCALogo from "../assets/whitelabels/MiPCA/MiPCALogo.png"; import StreamlineLogo from "../assets/whitelabels/Streamline/stream-logo.png" +import IntellifarmsLogo from "../assets/whitelabels/Intellifarms/IFND-2023-Logo.png" +import IntellifarmsLogoWhite from "../assets/whitelabels/Intellifarms/IFND-2023-Logo-White.png" // import { green, yellow } from "@mui/material/colors"; const protips: string[] = [ @@ -182,15 +184,15 @@ export function IsAdaptiveAgriculture(): boolean { const INTELLIFARMS_WHITE_LABEL: WhiteLabel = { name: "Intellifarms", - primaryColour: "", - secondaryColour: "", - signatureColour: "", - signatureAccentColour: "", + primaryColour: "#9E1B32", + secondaryColour: "#4A4F55", + signatureColour: "#272727", + signatureAccentColour: "#fff", auth0ClientId: import.meta.env.VITE_AUTH0_INTELLIFARMS_CLIENT_ID, redirectOnLogout: true, logoutRedirectTarget: "https://myintellifarms.com", - darkLogo: "", - lightLogo: "", + darkLogo: IntellifarmsLogo, + lightLogo: IntellifarmsLogoWhite, transparentLogoBG: true, blacklist: [], docs: "Platform", @@ -364,7 +366,7 @@ export function getWhitelabel(): WhiteLabel { return BXT_WHITE_LABEL; } if (window.location.origin.includes("localhost")) { - return STREAMLINE_WHITE_LABEL; + return INTELLIFARMS_WHITE_LABEL; } if (window.location.origin.includes("staging") || import.meta.env.VITE_LOCAL_STAGING=='true') { return STAGING_WHITELABEL; From ee416db01302d793e05f7e14a9436d8ef8279ce7 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Fri, 10 Apr 2026 16:22:48 -0600 Subject: [PATCH 034/146] 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 --- package-lock.json | 468 +++++++++++++++++- package.json | 3 +- src/3dModels/Shapes/3D/Sphere.tsx | 43 ++ src/bin/3dView/Bin3dView.tsx | 12 +- src/bin/3dView/BinCables/BinCables.tsx | 200 ++++++++ src/bin/3dView/BinCables/CableNode.tsx | 110 ++++ src/bin/3dView/BinCables/GrainCable.tsx | 86 ++++ .../GrainFillFlat.tsx | 0 src/models/Bin.ts | 8 + 9 files changed, 911 insertions(+), 19 deletions(-) create mode 100644 src/3dModels/Shapes/3D/Sphere.tsx create mode 100644 src/bin/3dView/BinCables/BinCables.tsx create mode 100644 src/bin/3dView/BinCables/CableNode.tsx create mode 100644 src/bin/3dView/BinCables/GrainCable.tsx rename src/bin/3dView/{grainFill => BinInventory}/GrainFillFlat.tsx (100%) diff --git a/package-lock.json b/package-lock.json index 348bfdd..2b0371b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@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", @@ -64,7 +65,7 @@ "react-virtualized-auto-sizer": "^1.0.25", "recharts": "^2.15.1", "semver": "^7.7.1", - "three": "^0.183.2", + "three": "^0.152.2", "victory": "^37.3.6", "weather-icons-react": "^1.2.0" }, @@ -2480,6 +2481,12 @@ "gl-style-validate": "dist/gl-style-validate.mjs" } }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.8.tgz", + "integrity": "sha512-Rp7ll8BHrKB3wXaRFKhrltwZl1CiXGdibPxuWXvqGnKTnv8fqa/nvftYNuSbf+pbJWKYCXdBtYTITdAUTGGh0Q==", + "license": "Apache-2.0" + }, "node_modules/@mui/core-downloads-tracker": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.5.0.tgz", @@ -3232,6 +3239,134 @@ "@react-pdf/stylesheet": "^6.1.2" } }, + "node_modules/@react-spring/animated": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.6.1.tgz", + "integrity": "sha512-ls/rJBrAqiAYozjLo5EPPLLOb1LM0lNVQcXODTC1SMtS6DbuBCPaKco5svFUQFMP2dso3O+qcC4k9FsKc0KxMQ==", + "license": "MIT", + "dependencies": { + "@react-spring/shared": "~9.6.1", + "@react-spring/types": "~9.6.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.6.1.tgz", + "integrity": "sha512-3HAAinAyCPessyQNNXe5W0OHzRfa8Yo5P748paPcmMowZ/4sMfaZ2ZB6e5x5khQI8NusOHj8nquoutd6FRY5WQ==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.6.1", + "@react-spring/rafz": "~9.6.1", + "@react-spring/shared": "~9.6.1", + "@react-spring/types": "~9.6.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.6.1.tgz", + "integrity": "sha512-v6qbgNRpztJFFfSE3e2W1Uz+g8KnIBs6SmzCzcVVF61GdGfGOuBrbjIcp+nUz301awVmREKi4eMQb2Ab2gGgyQ==", + "license": "MIT" + }, + "node_modules/@react-spring/shared": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.6.1.tgz", + "integrity": "sha512-PBFBXabxFEuF8enNLkVqMC9h5uLRBo6GQhRMQT/nRTnemVENimgRd+0ZT4yFnAQ0AxWNiJfX3qux+bW2LbG6Bw==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~9.6.1", + "@react-spring/types": "~9.6.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/three": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/three/-/three-9.6.1.tgz", + "integrity": "sha512-Tyw2YhZPKJAX3t2FcqvpLRb71CyTe1GvT3V+i+xJzfALgpk10uPGdGaQQ5Xrzmok1340DAeg2pR/MCfaW7b8AA==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.6.1", + "@react-spring/core": "~9.6.1", + "@react-spring/shared": "~9.6.1", + "@react-spring/types": "~9.6.1" + }, + "peerDependencies": { + "@react-three/fiber": ">=6.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "three": ">=0.126" + } + }, + "node_modules/@react-spring/types": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.6.1.tgz", + "integrity": "sha512-POu8Mk0hIU3lRXB3bGIGe4VHIwwDsQyoD1F394OK7STTiX9w4dG3cTLljjYswkQN+hDSHRrj4O36kuVa7KPU8Q==", + "license": "MIT" + }, + "node_modules/@react-three/drei": { + "version": "9.105.6", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-9.105.6.tgz", + "integrity": "sha512-JBgYeV36N9N9f1c3o1ZfLYW4rXZA7UQTq32Y8s3DEF6lwj1/y+RP/yq2VG5I8OzUPl7gsmWdy8fpWZgrlAqUpQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@mediapipe/tasks-vision": "0.10.8", + "@monogrid/gainmap-js": "^3.0.5", + "@react-spring/three": "~9.6.1", + "@use-gesture/react": "^10.2.24", + "camera-controls": "^2.4.2", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.28", + "glsl-noise": "^0.0.0", + "hls.js": "1.3.5", + "maath": "^0.10.7", + "meshline": "^3.1.6", + "react-composer": "^5.0.3", + "stats-gl": "^2.0.0", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.7.0", + "three-stdlib": "^2.29.9", + "troika-three-text": "^0.49.0", + "tunnel-rat": "^0.1.2", + "utility-types": "^3.10.0", + "uuid": "^9.0.1", + "zustand": "^3.7.1" + }, + "peerDependencies": { + "@react-three/fiber": ">=8.0", + "react": ">=18.0", + "react-dom": ">=18.0", + "three": ">=0.137" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/drei/node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, "node_modules/@react-three/fiber": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz", @@ -5760,6 +5895,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -5919,6 +6060,12 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "license": "MIT" }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -6388,6 +6535,24 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, "node_modules/@vis.gl/react-mapbox": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/@vis.gl/react-mapbox/-/react-mapbox-8.1.0.tgz", @@ -7177,6 +7342,15 @@ "node": ">=8" } }, + "node_modules/camera-controls": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-2.10.1.tgz", + "integrity": "sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.126.1" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001770", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", @@ -7417,11 +7591,28 @@ "integrity": "sha512-aNWR3te65YiaVFu/iwdqOo3cyUBZHUheE4d6EtgQu/T18jh/9SpoYXjXF/OzUD3Cqy0pGryoqtuy5gxD8tqX9Q==", "license": "MIT" }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -7921,6 +8112,15 @@ "node": ">=0.4.0" } }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, "node_modules/dfa": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", @@ -7955,6 +8155,12 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -9099,6 +9305,12 @@ "dev": true, "license": "MIT" }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "license": "MIT" + }, "node_modules/goober": { "version": "2.1.18", "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", @@ -9258,6 +9470,12 @@ "node": ">= 0.4" } }, + "node_modules/hls.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.3.5.tgz", + "integrity": "sha512-uybAvKS6uDe0MnWNEPnO0krWVr+8m2R0hJ/viql8H3MVK+itq8gGQuIYoFHL3rECkIpNH98Lw8YuuWMKZxp3Ew==", + "license": "Apache-2.0" + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -9386,6 +9604,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -9761,6 +9985,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -9946,7 +10176,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/isobject": { @@ -10309,6 +10538,15 @@ "integrity": "sha512-rDU6bkpuMs8YRt/UpkuYEAsYSoNuDEbrE41I3KNvmXREGH6DGBJ8Wbak4by29wNOQ27zk4g4HL82zf0OGhwRuw==", "license": "MIT" }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/linebreak": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", @@ -10456,6 +10694,16 @@ "yallist": "^3.0.2" } }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -10600,6 +10848,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, "node_modules/meshoptimizer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz", @@ -11137,7 +11394,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11378,6 +11634,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -11585,6 +11851,18 @@ "react": "*" } }, + "node_modules/react-composer": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/react-composer/-/react-composer-5.0.3.tgz", + "integrity": "sha512-1uWd07EME6XZvMfapwZmc7NgCZqDemcvicRi3wMJzXsQLvZ3L7fTHVyPy1bZdnWXM4iPjYuNE+uJ41MLKeTtnA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.6.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-day-picker": { "version": "9.13.2", "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.13.2.tgz", @@ -12635,7 +12913,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -12648,7 +12925,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12973,6 +13249,32 @@ "dev": true, "license": "MIT" }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "license": "MIT" + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -13313,11 +13615,49 @@ } }, "node_modules/three": { - "version": "0.183.2", - "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", - "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", + "version": "0.152.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.152.2.tgz", + "integrity": "sha512-Ff9zIpSfkkqcBcpdiFo2f35vA9ZucO+N8TNacJOqaEE6DrB0eufItVMib8bK8Pcju/ZNT6a7blE1GhTpkdsILw==", "license": "MIT" }, + "node_modules/three-mesh-bvh": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.7.6.tgz", + "integrity": "sha512-rCjsnxEqR9r1/C/lCqzGLS67NDty/S/eT6rAJfDvsanrIctTWdNoR4ZOGWewCB13h1QkVo2BpmC0wakj1+0m8A==", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.151.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "license": "MIT" + }, + "node_modules/three-stdlib/node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", @@ -13460,6 +13800,36 @@ "node": ">=8" } }, + "node_modules/troika-three-text": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.49.1.tgz", + "integrity": "sha512-lXGWxgjJP9kw4i4Wh+0k0Q/7cRfS6iOME4knKht/KozPu9GcFA9NnNpRvehIhrUawq9B0ZRw+0oiFHgRO+4Wig==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.49.0", + "troika-worker-utils": "^0.49.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.49.0.tgz", + "integrity": "sha512-umitFL4cT+Fm/uONmaQEq4oZlyRHWwVClaS6ZrdcueRvwc2w+cpNQ47LlJKJswpqtMFWbEhOLy0TekmcPZOdYA==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.49.0.tgz", + "integrity": "sha512-1xZHoJrG0HFfCvT/iyN41DvI/nRykiBtHqFkGaGgJwq5iXfIZFBiPPEHFpPpgyKM3Oo5ITHXP5wM2TNQszYdVg==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -13500,6 +13870,43 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -13850,12 +14257,43 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -14890,6 +15328,17 @@ "integrity": "sha512-VlLyZb9rb0Ir/NvC6T3YOcnftzUMg8hDYMb9xf1pzzM5z5wKogL6q1wZeqE5e+oHSw0CVcTFtYCbSa1ZEwnDZQ==", "license": "MIT" }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -14916,7 +15365,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/package.json b/package.json index 32de8f2..9cb57a3 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@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", @@ -76,7 +77,7 @@ "react-virtualized-auto-sizer": "^1.0.25", "recharts": "^2.15.1", "semver": "^7.7.1", - "three": "^0.183.2", + "three": "^0.152.2", "victory": "^37.3.6", "weather-icons-react": "^1.2.0" }, diff --git a/src/3dModels/Shapes/3D/Sphere.tsx b/src/3dModels/Shapes/3D/Sphere.tsx new file mode 100644 index 0000000..6104369 --- /dev/null +++ b/src/3dModels/Shapes/3D/Sphere.tsx @@ -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 { + 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 ; +} \ No newline at end of file diff --git a/src/bin/3dView/Bin3dView.tsx b/src/bin/3dView/Bin3dView.tsx index 0f700a4..9322e48 100644 --- a/src/bin/3dView/Bin3dView.tsx +++ b/src/bin/3dView/Bin3dView.tsx @@ -2,7 +2,8 @@ import OrbitCameraControls from "3dModels/CameraControls/OrbitCameraControls"; import { Canvas } from "@react-three/fiber"; import { Bin } from "models"; import BinShell from "./BinParts/BinShell"; -import GrainFillFlat from "./grainFill/GrainFillFlat"; +import GrainFillFlat from "./BinInventory/GrainFillFlat"; +import BinCables from "./BinCables/BinCables"; interface Props { /** @@ -53,8 +54,9 @@ export default function Bin3dView(props: Props){ fillPercent={fillPercent} /> )} - {/* cables */} + + {/* lighting */} @@ -62,12 +64,6 @@ export default function Bin3dView(props: Props){ - {/* */} - {/* camera controls */} ) diff --git a/src/bin/3dView/BinCables/BinCables.tsx b/src/bin/3dView/BinCables/BinCables.tsx new file mode 100644 index 0000000..a4026d2 --- /dev/null +++ b/src/bin/3dView/BinCables/BinCables.tsx @@ -0,0 +1,200 @@ +import GrainCable, { CableData } from "./GrainCable"; +import { avg } from "utils"; +import { Vector3 } from "three"; +import { useMemo } from "react"; +import { Bin } from "models"; +import React from "react"; + +interface CableOrbit { + orbit: number; + radius: number; // in cm + expectedCount: number; + assignedCount?: number; +} + +interface Props { + bin: Bin +} + +export default function BinCables(props: Props){ + const {bin} = props + const 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 + })); + }; + + const 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; + }; + + const getTopY = (radiusFromCenter: number) => { + const binRadius = bin.diameter() / 2; + + const distanceFromEdge = binRadius - radiusFromCenter; + + const angleRad = bin.roofAngle() * (Math.PI / 180); + + const roofRise = Math.tan(angleRad) * distanceFromEdge; + + return bin.sidewallHeight() / 2 + roofRise; + }; + + const getBottomY = (radiusFromCenter: number) => { + const binRadius = bin.diameter() / 2; + + const distanceFromCenter = binRadius - radiusFromCenter; + + const angleRad = bin.hopperAngle() * (Math.PI / 180); + + const hopperDrop = Math.tan(angleRad) * distanceFromCenter; + + const clearance = 60 //this is in cm + + return -bin.sidewallHeight() / 2 - hopperDrop + clearance; + }; + + const buildCablePositions = (bin: Bin): 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(bin.diameter()); + const distributed = distributeCables(cables.length, layout); + + + const cableRadius = 5; + + const result: CableData[] = []; + + let cableIndex = 0; + + distributed.forEach(orbit => { + const count = orbit.assignedCount ?? 0; + + const topY = getTopY(orbit.radius) + const bottomY = getBottomY(orbit.radius) + + if (orbit.orbit === 0 && count > 0) { + const cable = cables[cableIndex++]; + + result.push({ + radius: cableRadius, + topPosition: new Vector3(0, topY, 0), + bottomPosition: new Vector3(0, bottomY, 0), + grainCable: cable + // optionally attach cable data later + }); + 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({ + radius: cableRadius, + topPosition: new Vector3(x,topY,z), + bottomPosition: new Vector3(x,bottomY,z), + grainCable: cable + }); + } + }); + + return result; + }; + + const cables3D = useMemo(() => { + return buildCablePositions(bin); + }, [bin]); + + return ( + + {cables3D.map(cable => ( + + ))} + + ) +} \ No newline at end of file diff --git a/src/bin/3dView/BinCables/CableNode.tsx b/src/bin/3dView/BinCables/CableNode.tsx new file mode 100644 index 0000000..ac3235e --- /dev/null +++ b/src/bin/3dView/BinCables/CableNode.tsx @@ -0,0 +1,110 @@ +import Sphere, { SphereGeometry } from "3dModels/Shapes/3D/Sphere" +import { useFrame, useThree } from "@react-three/fiber" +import { Billboard, Text } from "@react-three/drei" +import React, { useMemo, useState } from "react" +import { Vector3 } from "three" +import { cloneDeep } from "lodash" +import { describeMeasurement } from "pbHelpers/MeasurementDescriber" +import { quack } from "protobuf-ts/quack" + +export interface NodeData { + radius: number + position: Vector3 + celcius: number + humidity?: number + moisture?: number + topNode?: boolean +} + +interface Props { + node: NodeData +} + +export default function CableNode(props: Props){ + const {node} = props + const { camera } = useThree(); + const [showLabel, setShowLabel] = useState(false); + + useFrame(() => { + //use the cameras distance to the center of the bin + const distance = camera.position.distanceTo(new Vector3(0, 0, 0)) + console.log(distance) + + const SHOW_DISTANCE = 15; + const HIDE_DISTANCE = 16; // hysteresis buffer + + if (!showLabel && distance < SHOW_DISTANCE) { + setShowLabel(true); + } else if (showLabel && distance > HIDE_DISTANCE) { + setShowLabel(false); + } + }); + + const geometry = React.useMemo(() => ({ + radius: node.radius, + } as SphereGeometry), [node]); + + const labelPosition = node.position.clone(); + + const dir = camera.position.clone().sub(node.position).normalize(); + + // push label slightly toward camera + labelPosition.add(dir.multiplyScalar(1)); // small offset like 0.5–2 units + + return ( + + {!showLabel ? + + : + + + {/* Background */} + + + + + + {/* Temp Text */} + + {node.celcius.toFixed(2)}°C + + {/* Humidity Text */} + {node.humidity && + + {node.humidity.toFixed(2)}% + + } + {/* Moisture Text */} + {node.moisture && + + {node.moisture.toFixed(2)}% EMC + + } + + + } + + + ) +} \ No newline at end of file diff --git a/src/bin/3dView/BinCables/GrainCable.tsx b/src/bin/3dView/BinCables/GrainCable.tsx new file mode 100644 index 0000000..d7c70e7 --- /dev/null +++ b/src/bin/3dView/BinCables/GrainCable.tsx @@ -0,0 +1,86 @@ +import Cylinder, { CylinderGeometry } from "3dModels/Shapes/3D/Cylinder" +import { pond } from "protobuf-ts/pond" +import React from "react" +import { Vector3 } from "three" +import CableNode, { NodeData } from "./CableNode" + +export interface CableData { + radius: number + topPosition: Vector3 + bottomPosition: Vector3 + grainCable: pond.GrainCable + radialSegments?: number + heightSegments?: number +} + +interface Props { + cable: CableData +} +export default function GrainCable(props: Props) { + const {cable} = 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 buildCableNodes = () => { + const nodeRadius = 20 + 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; + + let nodeData: NodeData[] = [] + grainCable.celcius.forEach((celcius,i) => { + const nodeY = bottomY + (i * spacing); + + const humidity = grainCable.relativeHumidity[i] || undefined; + const moisture = grainCable.moisture[i] || undefined; + + nodeData.push({ + radius: nodeRadius, + position: new Vector3(cable.bottomPosition.x, nodeY, cable.bottomPosition.z), + celcius: celcius, + humidity: humidity, + moisture: moisture, + topNode: grainCable.topNode === i + }) + + }) + + return nodeData + } + + const geometry = React.useMemo(() => ({ + radiusTop: cable.radius, + radiusBottom: cable.radius, + height: calculateHeight(), + radialSegments: cable.radialSegments ?? 10, + heightSegments: cable.heightSegments ?? 2 + } as CylinderGeometry), [cable]); + + return ( + + + {buildCableNodes().map(node => ( + + ))} + + ) +} \ No newline at end of file diff --git a/src/bin/3dView/grainFill/GrainFillFlat.tsx b/src/bin/3dView/BinInventory/GrainFillFlat.tsx similarity index 100% rename from src/bin/3dView/grainFill/GrainFillFlat.tsx rename to src/bin/3dView/BinInventory/GrainFillFlat.tsx diff --git a/src/models/Bin.ts b/src/models/Bin.ts index 7f9676d..485358e 100644 --- a/src/models/Bin.ts +++ b/src/models/Bin.ts @@ -72,6 +72,14 @@ export class Bin { return this.settings.specs?.advancedDimensions?.hopperHeight ?? 0; } + public roofAngle() : number { + return this.settings.specs?.advancedDimensions?.roofAngle ?? 0; + } + + public hopperAngle() : number { + return this.settings.specs?.advancedDimensions?.hopperAngle ?? 0; + } + public key(): string { return this.settings.key; } From f5db701d3c3c3ab700f479c3798593e4a3df204a Mon Sep 17 00:00:00 2001 From: csawatzky Date: Mon, 13 Apr 2026 14:46:20 -0600 Subject: [PATCH 035/146] 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 --- src/3dModels/Shapes/3D/Ring.tsx | 40 +++++ src/bin/3dView/Bin3dView.tsx | 5 +- src/bin/3dView/BinCables/BinCables.tsx | 7 +- src/bin/3dView/BinCables/CableNode.tsx | 198 +++++++++++++++++------- src/bin/3dView/BinCables/GrainCable.tsx | 13 +- src/bin/BinSVGV2.tsx | 4 +- src/models/Bin.ts | 8 + 7 files changed, 209 insertions(+), 66 deletions(-) create mode 100644 src/3dModels/Shapes/3D/Ring.tsx diff --git a/src/3dModels/Shapes/3D/Ring.tsx b/src/3dModels/Shapes/3D/Ring.tsx new file mode 100644 index 0000000..1a79d40 --- /dev/null +++ b/src/3dModels/Shapes/3D/Ring.tsx @@ -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 { + 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 ; +} \ No newline at end of file diff --git a/src/bin/3dView/Bin3dView.tsx b/src/bin/3dView/Bin3dView.tsx index 9322e48..6b6213d 100644 --- a/src/bin/3dView/Bin3dView.tsx +++ b/src/bin/3dView/Bin3dView.tsx @@ -4,6 +4,8 @@ import { Bin } from "models"; import BinShell from "./BinParts/BinShell"; import GrainFillFlat from "./BinInventory/GrainFillFlat"; import BinCables from "./BinCables/BinCables"; +import { Vector3 } from "three"; +import { useMemo } from "react"; interface Props { /** @@ -27,6 +29,7 @@ export default function Bin3dView(props: Props){ // and either a cone for the hopper or circle for flat bottom, it is possible to also use lathe geometry for this as well, // it might even work better because we can control the smoothness easier with it being only one mesh rather than 3 const {bin, scale = 100, fillPercent} = props + const binCenter = useMemo(() => new Vector3(0, 0, 0), []); console.log(bin) @@ -55,7 +58,7 @@ export default function Bin3dView(props: Props){ /> )} {/* cables */} - + {/* lighting */} diff --git a/src/bin/3dView/BinCables/BinCables.tsx b/src/bin/3dView/BinCables/BinCables.tsx index a4026d2..e7b5684 100644 --- a/src/bin/3dView/BinCables/BinCables.tsx +++ b/src/bin/3dView/BinCables/BinCables.tsx @@ -14,10 +14,11 @@ interface CableOrbit { interface Props { bin: Bin + binCenter: Vector3 } export default function BinCables(props: Props){ - const {bin} = props + const {bin, binCenter} = props const getLayoutFromDiameter = (diameterCm: number): CableOrbit[] => { const diameterFt = diameterCm / 30.48; @@ -192,8 +193,8 @@ export default function BinCables(props: Props){ return ( - {cables3D.map(cable => ( - + {cables3D.map((cable, i) => ( + ))} ) diff --git a/src/bin/3dView/BinCables/CableNode.tsx b/src/bin/3dView/BinCables/CableNode.tsx index ac3235e..a9803fc 100644 --- a/src/bin/3dView/BinCables/CableNode.tsx +++ b/src/bin/3dView/BinCables/CableNode.tsx @@ -1,11 +1,13 @@ import Sphere, { SphereGeometry } from "3dModels/Shapes/3D/Sphere" import { useFrame, useThree } from "@react-three/fiber" import { Billboard, Text } from "@react-three/drei" -import React, { useMemo, useState } from "react" -import { Vector3 } from "three" -import { cloneDeep } from "lodash" +import React, { useMemo, useRef, useState } from "react" +import { Euler, Group, Vector3 } from "three" import { describeMeasurement } from "pbHelpers/MeasurementDescriber" import { quack } from "protobuf-ts/quack" +import { useGlobalState } from "providers" +import { pond } from "protobuf-ts/pond" +import Ring, { RingGeometry } from "3dModels/Shapes/3D/Ring" export interface NodeData { radius: number @@ -14,21 +16,29 @@ export interface NodeData { humidity?: number moisture?: number topNode?: boolean + excluded?: boolean + inGrain?: boolean } interface Props { node: NodeData + binCenter: Vector3 + lowerThreshold: number + upperThreshold: number } export default function CableNode(props: Props){ - const {node} = props + const {node, binCenter, lowerThreshold, upperThreshold} = props const { camera } = useThree(); + const [{user}] = useGlobalState(); const [showLabel, setShowLabel] = useState(false); + const labelGroupRef = React.useRef(null); + const ROW_HEIGHT = 35; + const PADDING = 20; useFrame(() => { //use the cameras distance to the center of the bin - const distance = camera.position.distanceTo(new Vector3(0, 0, 0)) - console.log(distance) + const distance = camera.position.distanceTo(binCenter) const SHOW_DISTANCE = 15; const HIDE_DISTANCE = 16; // hysteresis buffer @@ -38,73 +48,147 @@ export default function CableNode(props: Props){ } else if (showLabel && distance > HIDE_DISTANCE) { setShowLabel(false); } + + const scale = distance * 0.07; + + if (labelGroupRef.current) { + labelGroupRef.current.scale.set(scale, scale, 1); + } }); + const tempDisplay = () => { + let temp = node.celcius.toFixed(2) + "°C" + if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { + temp = ((node.celcius * 9/5) + 32).toFixed(2) + "°F" + } + return temp + } + + const nodeColour = () => { + if (!node.inGrain) return "white"; + + const lower = lowerThreshold; + const upper = upperThreshold; + + if (lower !== undefined && node.celcius < lower) { + return "#3399ff"; // blue + } + + if (upper !== undefined && node.celcius > upper) { + return "#ff4d4f"; // red + } + + return "#52c41a"; // green + }; + + const rows = useMemo(() => { + const r: { text: string; color: string }[] = []; + + if (node.excluded) { + r.push({ + text: "Excluded", + color: "red" + }); + return r; // nothing else matters + } + + if (node.topNode) { + r.push({ + text: "Top Node", + color: "yellow" + }); + } + + r.push({ + text: tempDisplay(), + color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour() + }); + + if (node.humidity) { + r.push({ + text: `${node.humidity.toFixed(2)}%`, + color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour() + }); + } + + if (node.moisture) { + r.push({ + text: `${node.moisture.toFixed(2)}% EMC`, + color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour() + }); + } + + return r; + }, [node, user]); + 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 labelPosition = node.position.clone(); + const labelHeight = rows.length * ROW_HEIGHT + PADDING; const dir = camera.position.clone().sub(node.position).normalize(); // push label slightly toward camera labelPosition.add(dir.multiplyScalar(1)); // small offset like 0.5–2 units + const getRowY = (index: number) => { + const totalHeight = rows.length * ROW_HEIGHT; + return totalHeight / 2 - (index * ROW_HEIGHT) - ROW_HEIGHT / 2; + }; + + return ( - {!showLabel ? - - : - - - {/* Background */} - - - - - - {/* Temp Text */} - - {node.celcius.toFixed(2)}°C - - {/* Humidity Text */} - {node.humidity && - - {node.humidity.toFixed(2)}% - - } - {/* Moisture Text */} - {node.moisture && - - {node.moisture.toFixed(2)}% EMC - - } - - + {(!node.inGrain || !showLabel) && + + + {node.topNode && ( + + )} + + } + {node.inGrain && showLabel && + + + {/* Background */} + + + + + {/* Rows */} + {rows.map((row, i) => ( + + {row.text} + + ))} + + } - ) } \ No newline at end of file diff --git a/src/bin/3dView/BinCables/GrainCable.tsx b/src/bin/3dView/BinCables/GrainCable.tsx index d7c70e7..e889893 100644 --- a/src/bin/3dView/BinCables/GrainCable.tsx +++ b/src/bin/3dView/BinCables/GrainCable.tsx @@ -3,6 +3,7 @@ import { pond } from "protobuf-ts/pond" import React from "react" import { Vector3 } from "three" import CableNode, { NodeData } from "./CableNode" +import { Bin } from "models" export interface CableData { radius: number @@ -15,9 +16,11 @@ export interface CableData { interface Props { cable: CableData + bin: Bin + binCenter: Vector3 } export default function GrainCable(props: Props) { - const {cable} = props + const {cable, bin, binCenter} = props const calculateHeight = () => { return cable.topPosition.distanceTo(cable.bottomPosition) @@ -59,7 +62,9 @@ export default function GrainCable(props: Props) { celcius: celcius, humidity: humidity, moisture: moisture, - topNode: grainCable.topNode === i + topNode: grainCable.topNode === i+1, //top node tracking starts at 1 not 0 so add one to the index + excluded: grainCable.excludedNodes.includes(i), + inGrain: i+1 <= grainCable.topNode }) }) @@ -78,8 +83,8 @@ export default function GrainCable(props: Props) { return ( - {buildCableNodes().map(node => ( - + {buildCableNodes().map((node, i) => ( + ))} ) diff --git a/src/bin/BinSVGV2.tsx b/src/bin/BinSVGV2.tsx index ecbeb53..0a24d5c 100644 --- a/src/bin/BinSVGV2.tsx +++ b/src/bin/BinSVGV2.tsx @@ -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) { diff --git a/src/models/Bin.ts b/src/models/Bin.ts index 485358e..475958c 100644 --- a/src/models/Bin.ts +++ b/src/models/Bin.ts @@ -284,4 +284,12 @@ export class Bin { } return c; } + + public upperTempThreshold(): number { + return this.settings.highTemp + } + + public lowerTempThreshold(): number { + return this.settings.lowTemp + } } From e7ab6a2d76f356178f56cbaf05ed9640977a0f91 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Mon, 13 Apr 2026 14:46:39 -0600 Subject: [PATCH 036/146] working on panning, currently kinda glitchy --- .../CameraControls/OrbitCameraControls.tsx | 119 +++++++++++++++--- 1 file changed, 100 insertions(+), 19 deletions(-) diff --git a/src/3dModels/CameraControls/OrbitCameraControls.tsx b/src/3dModels/CameraControls/OrbitCameraControls.tsx index 20768b4..5d707c3 100644 --- a/src/3dModels/CameraControls/OrbitCameraControls.tsx +++ b/src/3dModels/CameraControls/OrbitCameraControls.tsx @@ -1,5 +1,6 @@ import { useThree } from "@react-three/fiber"; import { useEffect, useRef } from "react"; +import { Plane, Raycaster, Vector2, Vector3 } from "three"; interface CameraTarget { x: number; @@ -25,8 +26,18 @@ interface Props { export default function OrbitCameraControls(props: Props) { const { target, clampVerticalRotation, minPhi = 0.01, maxPhi = Math.PI - 0.01 } = props; const { camera, gl } = useThree(); + const panPlane = useRef(new Plane()); + const panStartPoint = useRef(new Vector3()); + const raycaster = useRef(new Raycaster()); + const mouse = useRef(new Vector2()); + const targetRef = useRef({ + x: target?.x ?? 0, + y: target?.y ?? 0, + z: target?.z ?? 0, + }); const isDragging = useRef(false); + const isPanning = useRef(false); const lastPos = useRef({ x: 0, y: 0 }); // Spherical coords @@ -38,7 +49,7 @@ export default function OrbitCameraControls(props: Props) { useEffect(() => { const canvas = gl.domElement; - + canvas.addEventListener("contextmenu", e => e.preventDefault()); const updateCamera = () => { const { radius, theta, phi } = spherical.current; @@ -47,41 +58,111 @@ export default function OrbitCameraControls(props: Props) { const z = radius * Math.sin(phi) * Math.cos(theta); camera.position.set(x, y, z); - camera.lookAt(target?.x ?? 0, target?.y ?? 0, target?.z ?? 0); + camera.lookAt( + targetRef.current.x, + targetRef.current.y, + targetRef.current.z + ); }; updateCamera(); const handleMouseDown = (e: MouseEvent) => { if (e.target !== canvas) return; - isDragging.current = true; + lastPos.current = { x: e.clientX, y: e.clientY }; - }; + + // Left click = rotate + if (e.button === 0) { + isDragging.current = true; + isPanning.current = false; + } + + // Right click = pan + if (e.button === 2) { + isPanning.current = true; + isDragging.current = false; - const handleMouseMove = (e: MouseEvent) => { - if (!isDragging.current) return; - - const deltaX = e.clientX - lastPos.current.x; - const deltaY = e.clientY - lastPos.current.y; - - spherical.current.theta -= deltaX * 0.005; - spherical.current.phi -= deltaY * 0.005; - - // Clamp vertical rotation (prevents flipping) - if (clampVerticalRotation) { - spherical.current.phi = Math.max( - minPhi, - Math.min(maxPhi, spherical.current.phi) + // plane facing camera through target + const camDir = new Vector3(); + camera.getWorldDirection(camDir); + + panPlane.current.setFromNormalAndCoplanarPoint( + camDir, + new Vector3( + targetRef.current.x, + targetRef.current.y, + targetRef.current.z + ) + ); + + // starting intersection point + raycaster.current.setFromCamera( + new Vector2( + (e.clientX / window.innerWidth) * 2 - 1, + -(e.clientY / window.innerHeight) * 2 + 1 + ), + camera + ); + + raycaster.current.ray.intersectPlane( + panPlane.current, + panStartPoint.current ); } + }; + + + const handleMouseMove = (e: MouseEvent) => { + const deltaX = e.clientX - lastPos.current.x; + const deltaY = e.clientY - lastPos.current.y; + + // ROTATE + 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 + if (isPanning.current) { + const raycaster = new Raycaster(); + + mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1; + mouse.current.y = -(e.clientY / window.innerHeight) * 2 + 1; + + raycaster.setFromCamera(mouse.current, camera); + + const currentPoint = new Vector3(); + + raycaster.ray.intersectPlane(panPlane.current, currentPoint); + + const delta = new Vector3().subVectors( + panStartPoint.current, + currentPoint + ); + + targetRef.current.x += delta.x; + targetRef.current.y += delta.y; + targetRef.current.z += delta.z; + + panStartPoint.current.copy(currentPoint); + } + lastPos.current = { x: e.clientX, y: e.clientY }; - updateCamera(); }; const handleMouseUp = () => { isDragging.current = false; + isPanning.current = false; }; const handleWheel = (e: WheelEvent) => { From 0556fe2d8b6ca25350948d1d4b93cb7460a0d983 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 14 Apr 2026 15:29:59 -0600 Subject: [PATCH 037/146] 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 --- src/bin/BinActions.tsx | 6 ++ src/bin/BinComponents.tsx | 116 ++++++++++++++++++++++++++++++---- src/bin/BinSensors.tsx | 6 ++ src/pages/Bin.tsx | 4 +- src/providers/pond/binAPI.tsx | 6 +- 5 files changed, 121 insertions(+), 17 deletions(-) diff --git a/src/bin/BinActions.tsx b/src/bin/BinActions.tsx index a425ea6..c9f7c8f 100644 --- a/src/bin/BinActions.tsx +++ b/src/bin/BinActions.tsx @@ -57,6 +57,8 @@ interface Props { components?: Map; setComponents?: React.Dispatch>>; updateBinStatus?: (componentKeys: string[], removed?: boolean) => void; + componentDevices?: Map + setComponentDevices?: React.Dispatch>>; } 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); @@ -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) { diff --git a/src/bin/BinComponents.tsx b/src/bin/BinComponents.tsx index 7864c46..0b81cba 100644 --- a/src/bin/BinComponents.tsx +++ b/src/bin/BinComponents.tsx @@ -71,7 +71,9 @@ const useStyles = makeStyles((theme: Theme) => { interface Props { components?: Map; + componentDevices?: Map; setComponents?: React.Dispatch>>; + setComponentDevices?: React.Dispatch>>; 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 ( setComponentToRemove(undefined)}> Remove {componentToRemove?.name()}? - - 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. - + {lastComponent ? + + 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. + + : + + 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. + + } + {lastComponent + ? + + + + : + } ); @@ -256,7 +310,7 @@ export default function BinComponents(props: Props) { Remove All Components? - 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. @@ -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) { {component.name()} - setComponentToRemove(component)}> + { + setComponentToRemove(component) + + }}> diff --git a/src/bin/BinSensors.tsx b/src/bin/BinSensors.tsx index f1c81ac..492091e 100644 --- a/src/bin/BinSensors.tsx +++ b/src/bin/BinSensors.tsx @@ -30,7 +30,9 @@ interface Props { coords?: { longitude: number; latitude: number }; binYards?: pond.BinYardSettings[]; components?: Map; + componentDevices?: Map setComponents?: React.Dispatch>>; + setComponentDevices?: React.Dispatch>>; 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) { { let device = Device.create(dev); if (attachedDevIds.includes(device.id())) { - devs.push(Device.create(dev)); + devs.push(device); } }); setDevices(devs); @@ -674,6 +674,8 @@ export default function Bin(props: Props) { userID={user.id()} components={components} setComponents={setComponents} + componentDevices={componentDevices} + setComponentDevices={setComponentDevices} updateBinStatus={updateStatus} /> diff --git a/src/providers/pond/binAPI.tsx b/src/providers/pond/binAPI.tsx index def6699..e0d46f9 100644 --- a/src/providers/pond/binAPI.tsx +++ b/src/providers/pond/binAPI.tsx @@ -30,6 +30,7 @@ export interface IBinAPIContext { removeComponent: ( bin: string, component: string, + device?: number, otherTeam?: string ) => Promise>; removeAllComponents: (bin: string, otherTeam?: string) => Promise>; @@ -254,10 +255,11 @@ export default function BinProvider(props: PropsWithChildren) { }) }; - const removeComponent = (bin: string, component: string, otherTeam?: string) => { + const removeComponent = (bin: string, component: string, device?: number, otherTeam?: string) => { const view = otherTeam ? otherTeam : as let url = "/bins/" + bin + "/removeComponent/" + component; - if (view) url = url + "?as=" + view; + if (device) url = url + "?deviceToRemove=" + device + if (view) url = url + (device ? "&as=" : "?as=") + view; return new Promise>((resolve, reject)=>{ post(pondURL(url)).then(resp => { resp.data = pond.RemoveBinComponentResponse.fromObject(resp.data) From f7b5a59c9804b2aadf384dc3c1d314e9981ec5af Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 14 Apr 2026 16:01:52 -0600 Subject: [PATCH 038/146] 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 --- src/charts/SingleSetAreaChart.tsx | 7 +++++-- src/gate/GateDeltaTempGraph.tsx | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/charts/SingleSetAreaChart.tsx b/src/charts/SingleSetAreaChart.tsx index 22477d6..b84d150 100644 --- a/src/charts/SingleSetAreaChart.tsx +++ b/src/charts/SingleSetAreaChart.tsx @@ -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(["dataMin", "dataMax"]); const [refLeft, setRefLeft] = useState(); const [refRight, setRefRight] = useState(); @@ -152,7 +154,8 @@ export default function SingleSetAreaChart(props: Props) { /> Date: Thu, 16 Apr 2026 16:27:08 -0600 Subject: [PATCH 039/146] 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 --- .../CameraControls/OrbitCameraControls.tsx | 118 ++++++---- src/bin/3dView/BinCables/BinCables.tsx | 201 ------------------ src/bin/3dView/BinCables/GrainCable.tsx | 91 -------- src/bin/3dView/Data/BuildCableData.ts | 194 +++++++++++++++++ src/bin/3dView/Data/BuildNodeData.ts | 60 ++++++ src/bin/3dView/{ => Scene}/Bin3dView.tsx | 15 +- src/bin/3dView/Systems/Cables/BinCables.tsx | 28 +++ .../Cables}/CableNode.tsx | 95 ++++++--- src/bin/3dView/Systems/Cables/GrainCable.tsx | 47 ++++ src/bin/3dView/Systems/Heatmap/Heatmap.tsx | 16 ++ .../Inventory}/GrainFillFlat.tsx | 0 src/bin/3dView/utils/tempToColour.ts | 68 ++++++ src/pages/Bin.tsx | 2 +- 13 files changed, 572 insertions(+), 363 deletions(-) delete mode 100644 src/bin/3dView/BinCables/BinCables.tsx delete mode 100644 src/bin/3dView/BinCables/GrainCable.tsx create mode 100644 src/bin/3dView/Data/BuildCableData.ts create mode 100644 src/bin/3dView/Data/BuildNodeData.ts rename src/bin/3dView/{ => Scene}/Bin3dView.tsx (83%) create mode 100644 src/bin/3dView/Systems/Cables/BinCables.tsx rename src/bin/3dView/{BinCables => Systems/Cables}/CableNode.tsx (65%) create mode 100644 src/bin/3dView/Systems/Cables/GrainCable.tsx create mode 100644 src/bin/3dView/Systems/Heatmap/Heatmap.tsx rename src/bin/3dView/{BinInventory => Systems/Inventory}/GrainFillFlat.tsx (100%) create mode 100644 src/bin/3dView/utils/tempToColour.ts diff --git a/src/3dModels/CameraControls/OrbitCameraControls.tsx b/src/3dModels/CameraControls/OrbitCameraControls.tsx index 5d707c3..0385cdd 100644 --- a/src/3dModels/CameraControls/OrbitCameraControls.tsx +++ b/src/3dModels/CameraControls/OrbitCameraControls.tsx @@ -26,9 +26,9 @@ interface Props { export default function OrbitCameraControls(props: Props) { const { target, clampVerticalRotation, minPhi = 0.01, maxPhi = Math.PI - 0.01 } = props; const { camera, gl } = useThree(); - const panPlane = useRef(new Plane()); - const panStartPoint = useRef(new Vector3()); - const raycaster = useRef(new Raycaster()); + // const panPlane = useRef(new Plane()); + // const panStartPoint = useRef(new Vector3()); + // const raycaster = useRef(new Raycaster()); const mouse = useRef(new Vector2()); const targetRef = useRef({ x: target?.x ?? 0, @@ -39,6 +39,7 @@ export default function OrbitCameraControls(props: Props) { const isDragging = useRef(false); const isPanning = useRef(false); const lastPos = useRef({ x: 0, y: 0 }); + const panCameraPosition = useRef(new Vector3()); // Spherical coords const spherical = useRef({ @@ -57,7 +58,11 @@ export default function OrbitCameraControls(props: Props) { const y = radius * Math.cos(phi); const z = radius * Math.sin(phi) * Math.cos(theta); - camera.position.set(x, y, z); + camera.position.set( + targetRef.current.x + x, + targetRef.current.y + y, + targetRef.current.z + z + ); camera.lookAt( targetRef.current.x, targetRef.current.y, @@ -71,6 +76,7 @@ export default function OrbitCameraControls(props: Props) { if (e.target !== canvas) return; lastPos.current = { x: e.clientX, y: e.clientY }; + panCameraPosition.current.copy(camera.position); // Left click = rotate if (e.button === 0) { @@ -87,28 +93,29 @@ export default function OrbitCameraControls(props: Props) { const camDir = new Vector3(); camera.getWorldDirection(camDir); - panPlane.current.setFromNormalAndCoplanarPoint( - camDir, - new Vector3( - targetRef.current.x, - targetRef.current.y, - targetRef.current.z - ) - ); + // panPlane.current.setFromNormalAndCoplanarPoint( + // camDir, + // new Vector3( + // targetRef.current.x, + // targetRef.current.y, + // targetRef.current.z + // ) + // ); + + // const rect = canvas.getBoundingClientRect(); + + // raycaster.current.setFromCamera( + // new Vector2( + // ((e.clientX - rect.left) / rect.width) * 2 - 1, + // -((e.clientY - rect.top) / rect.height) * 2 + 1 + // ), + // camera + // ); - // starting intersection point - raycaster.current.setFromCamera( - new Vector2( - (e.clientX / window.innerWidth) * 2 - 1, - -(e.clientY / window.innerHeight) * 2 + 1 - ), - camera - ); - - raycaster.current.ray.intersectPlane( - panPlane.current, - panStartPoint.current - ); + // raycaster.current.ray.intersectPlane( + // panPlane.current, + // panStartPoint.current + // ); } }; @@ -132,28 +139,61 @@ export default function OrbitCameraControls(props: Props) { } // PAN + // if (isPanning.current) { + // const rect = canvas.getBoundingClientRect(); + + // mouse.current.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; + // mouse.current.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; + + // const currentPoint = new Vector3(); + + // const tempCamera = camera.clone(); + // tempCamera.position.copy(panCameraPosition.current); + // tempCamera.updateMatrixWorld(); + + // raycaster.current.setFromCamera(mouse.current, tempCamera); + + // raycaster.current.ray.intersectPlane(panPlane.current, currentPoint); + + // const delta = new Vector3().subVectors( + // panStartPoint.current, + // currentPoint + // ); + + // targetRef.current.x += delta.x; + // targetRef.current.y += delta.y; + // targetRef.current.z += delta.z; + + // panStartPoint.current.copy(currentPoint); + // } if (isPanning.current) { - const raycaster = new Raycaster(); + const deltaX = e.clientX - lastPos.current.x; + const deltaY = e.clientY - lastPos.current.y; - mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1; - mouse.current.y = -(e.clientY / window.innerHeight) * 2 + 1; + // distance-based scaling (important for consistent feel) + const distance = spherical.current.radius; - raycaster.setFromCamera(mouse.current, camera); + const panSpeed = 0.002 * distance; - const currentPoint = new Vector3(); + const offset = new Vector3(); - raycaster.ray.intersectPlane(panPlane.current, currentPoint); + // get camera basis vectors + const right = new Vector3(); + const up = new Vector3(); - const delta = new Vector3().subVectors( - panStartPoint.current, - currentPoint - ); + camera.getWorldDirection(offset); // forward + right.crossVectors(offset, camera.up).normalize(); // right + up.copy(camera.up).normalize(); - targetRef.current.x += delta.x; - targetRef.current.y += delta.y; - targetRef.current.z += delta.z; + // apply movement + right.multiplyScalar(-deltaX * panSpeed); + up.multiplyScalar(deltaY * panSpeed); - panStartPoint.current.copy(currentPoint); + const pan = new Vector3().addVectors(right, up); + + targetRef.current.x += pan.x; + targetRef.current.y += pan.y; + targetRef.current.z += pan.z; } lastPos.current = { x: e.clientX, y: e.clientY }; diff --git a/src/bin/3dView/BinCables/BinCables.tsx b/src/bin/3dView/BinCables/BinCables.tsx deleted file mode 100644 index e7b5684..0000000 --- a/src/bin/3dView/BinCables/BinCables.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import GrainCable, { CableData } from "./GrainCable"; -import { avg } from "utils"; -import { Vector3 } from "three"; -import { useMemo } from "react"; -import { Bin } from "models"; -import React from "react"; - -interface CableOrbit { - orbit: number; - radius: number; // in cm - expectedCount: number; - assignedCount?: number; -} - -interface Props { - bin: Bin - binCenter: Vector3 -} - -export default function BinCables(props: Props){ - const {bin, binCenter} = props - const 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 - })); - }; - - const 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; - }; - - const getTopY = (radiusFromCenter: number) => { - const binRadius = bin.diameter() / 2; - - const distanceFromEdge = binRadius - radiusFromCenter; - - const angleRad = bin.roofAngle() * (Math.PI / 180); - - const roofRise = Math.tan(angleRad) * distanceFromEdge; - - return bin.sidewallHeight() / 2 + roofRise; - }; - - const getBottomY = (radiusFromCenter: number) => { - const binRadius = bin.diameter() / 2; - - const distanceFromCenter = binRadius - radiusFromCenter; - - const angleRad = bin.hopperAngle() * (Math.PI / 180); - - const hopperDrop = Math.tan(angleRad) * distanceFromCenter; - - const clearance = 60 //this is in cm - - return -bin.sidewallHeight() / 2 - hopperDrop + clearance; - }; - - const buildCablePositions = (bin: Bin): 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(bin.diameter()); - const distributed = distributeCables(cables.length, layout); - - - const cableRadius = 5; - - const result: CableData[] = []; - - let cableIndex = 0; - - distributed.forEach(orbit => { - const count = orbit.assignedCount ?? 0; - - const topY = getTopY(orbit.radius) - const bottomY = getBottomY(orbit.radius) - - if (orbit.orbit === 0 && count > 0) { - const cable = cables[cableIndex++]; - - result.push({ - radius: cableRadius, - topPosition: new Vector3(0, topY, 0), - bottomPosition: new Vector3(0, bottomY, 0), - grainCable: cable - // optionally attach cable data later - }); - 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({ - radius: cableRadius, - topPosition: new Vector3(x,topY,z), - bottomPosition: new Vector3(x,bottomY,z), - grainCable: cable - }); - } - }); - - return result; - }; - - const cables3D = useMemo(() => { - return buildCablePositions(bin); - }, [bin]); - - return ( - - {cables3D.map((cable, i) => ( - - ))} - - ) -} \ No newline at end of file diff --git a/src/bin/3dView/BinCables/GrainCable.tsx b/src/bin/3dView/BinCables/GrainCable.tsx deleted file mode 100644 index e889893..0000000 --- a/src/bin/3dView/BinCables/GrainCable.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import Cylinder, { CylinderGeometry } from "3dModels/Shapes/3D/Cylinder" -import { pond } from "protobuf-ts/pond" -import React from "react" -import { Vector3 } from "three" -import CableNode, { NodeData } from "./CableNode" -import { Bin } from "models" - -export interface CableData { - radius: number - topPosition: Vector3 - bottomPosition: Vector3 - grainCable: pond.GrainCable - radialSegments?: number - heightSegments?: number -} - -interface Props { - cable: CableData - bin: Bin - binCenter: Vector3 -} -export default function GrainCable(props: Props) { - const {cable, bin, binCenter} = 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 buildCableNodes = () => { - const nodeRadius = 20 - 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; - - let nodeData: NodeData[] = [] - grainCable.celcius.forEach((celcius,i) => { - const nodeY = bottomY + (i * spacing); - - const humidity = grainCable.relativeHumidity[i] || undefined; - const moisture = grainCable.moisture[i] || undefined; - - nodeData.push({ - radius: nodeRadius, - position: new Vector3(cable.bottomPosition.x, nodeY, cable.bottomPosition.z), - celcius: celcius, - humidity: humidity, - moisture: moisture, - topNode: grainCable.topNode === i+1, //top node tracking starts at 1 not 0 so add one to the index - excluded: grainCable.excludedNodes.includes(i), - inGrain: i+1 <= grainCable.topNode - }) - - }) - - return nodeData - } - - const geometry = React.useMemo(() => ({ - radiusTop: cable.radius, - radiusBottom: cable.radius, - height: calculateHeight(), - radialSegments: cable.radialSegments ?? 10, - heightSegments: cable.heightSegments ?? 2 - } as CylinderGeometry), [cable]); - - return ( - - - {buildCableNodes().map((node, i) => ( - - ))} - - ) -} \ No newline at end of file diff --git a/src/bin/3dView/Data/BuildCableData.ts b/src/bin/3dView/Data/BuildCableData.ts new file mode 100644 index 0000000..f9493a0 --- /dev/null +++ b/src/bin/3dView/Data/BuildCableData.ts @@ -0,0 +1,194 @@ +import { Bin } from "models"; +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; +} + + +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, bin: Bin) { + const binRadius = bin.diameter() / 2; + + const distanceFromEdge = binRadius - radiusFromCenter; + + const angleRad = bin.roofAngle() * (Math.PI / 180); + + const roofRise = Math.tan(angleRad) * distanceFromEdge; + + return bin.sidewallHeight() / 2 + roofRise; +}; + +function getBottomY(radiusFromCenter: number, bin: Bin) { + const binRadius = bin.diameter() / 2; + + const distanceFromCenter = binRadius - radiusFromCenter; + + const angleRad = bin.hopperAngle() * (Math.PI / 180); + + const hopperDrop = Math.tan(angleRad) * distanceFromCenter; + + const clearance = 60 //this is in cm + + return -bin.sidewallHeight() / 2 - hopperDrop + clearance; +}; + + +export function BuildCableData(bin: Bin): 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(bin.diameter()); + const distributed = distributeCables(cables.length, layout); + + + const cableRadius = 5; + + const result: CableData[] = []; + + let cableIndex = 0; + + distributed.forEach(orbit => { + const count = orbit.assignedCount ?? 0; + + const topY = getTopY(orbit.radius, bin) + const bottomY = getBottomY(orbit.radius, bin) + + 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; +}; \ No newline at end of file diff --git a/src/bin/3dView/Data/BuildNodeData.ts b/src/bin/3dView/Data/BuildNodeData.ts new file mode 100644 index 0000000..de14d23 --- /dev/null +++ b/src/bin/3dView/Data/BuildNodeData.ts @@ -0,0 +1,60 @@ +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 +} + +export function BuildNodeData(cables: CableData[]): NodeData[] { + const nodeRadius = 20 + 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; + + nodeData.push({ + cableIndex: cableIndex, + nodeIndex: i, + radius: nodeRadius, + position: new Vector3(cable.bottomPosition.x, nodeY, cable.bottomPosition.z), + celcius: celcius, + humidity: humidity, + moisture: moisture, + topNode: grainCable.topNode === i+1, //top node tracking starts at 1 not 0 so add one to the index + excluded: grainCable.excludedNodes?.includes(i) ?? false, + inGrain: i+1 <= grainCable.topNode + // optional: (may want to add this to the node data later) + // normalizedHeight: (nodeY - bottomY) / height + }) + + }) + }) + + return nodeData +} \ No newline at end of file diff --git a/src/bin/3dView/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx similarity index 83% rename from src/bin/3dView/Bin3dView.tsx rename to src/bin/3dView/Scene/Bin3dView.tsx index 6b6213d..5be25d8 100644 --- a/src/bin/3dView/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -1,11 +1,13 @@ import OrbitCameraControls from "3dModels/CameraControls/OrbitCameraControls"; import { Canvas } from "@react-three/fiber"; import { Bin } from "models"; -import BinShell from "./BinParts/BinShell"; -import GrainFillFlat from "./BinInventory/GrainFillFlat"; -import BinCables from "./BinCables/BinCables"; +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 } from "../Data/BuildCableData"; +import { BuildNodeData } from "../Data/BuildNodeData"; interface Props { /** @@ -30,8 +32,8 @@ export default function Bin3dView(props: Props){ // it might even work better because we can control the smoothness easier with it being only one mesh rather than 3 const {bin, scale = 100, fillPercent} = props const binCenter = useMemo(() => new Vector3(0, 0, 0), []); - - console.log(bin) + const cableData = useMemo(() => BuildCableData(bin), [bin]); + const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]); return ( @@ -46,6 +48,7 @@ export default function Bin3dView(props: Props){ sidewallHeight={bin.sidewallHeight()} roofHeight={bin.roofHeight()} hopperHeight={bin.hopperHeight()} + /> {/* grain - cylinder*/} @@ -58,7 +61,7 @@ export default function Bin3dView(props: Props){ /> )} {/* cables */} - + {/* lighting */} diff --git a/src/bin/3dView/Systems/Cables/BinCables.tsx b/src/bin/3dView/Systems/Cables/BinCables.tsx new file mode 100644 index 0000000..452a471 --- /dev/null +++ b/src/bin/3dView/Systems/Cables/BinCables.tsx @@ -0,0 +1,28 @@ +import GrainCable from "./GrainCable"; +import { Vector3 } from "three"; +import { Bin } from "models"; +import React from "react"; +import { CableData } from "../../Data/BuildCableData"; +import { NodeData } from "../../Data/BuildNodeData"; + +interface Props { + cableData: CableData[] + nodeData: NodeData[] + bin: Bin + binCenter: Vector3 +} + +export default function BinCables(props: Props){ + const {bin, binCenter, cableData, nodeData} = props + return ( + + {cableData.map((cable, i) => { + const cableNodes = nodeData.filter(n => n.cableIndex === cable.cableIndex); + return ( + + )} + )} + + + ) +} \ No newline at end of file diff --git a/src/bin/3dView/BinCables/CableNode.tsx b/src/bin/3dView/Systems/Cables/CableNode.tsx similarity index 65% rename from src/bin/3dView/BinCables/CableNode.tsx rename to src/bin/3dView/Systems/Cables/CableNode.tsx index a9803fc..b0b7307 100644 --- a/src/bin/3dView/BinCables/CableNode.tsx +++ b/src/bin/3dView/Systems/Cables/CableNode.tsx @@ -1,24 +1,16 @@ import Sphere, { SphereGeometry } from "3dModels/Shapes/3D/Sphere" import { useFrame, useThree } from "@react-three/fiber" import { Billboard, Text } from "@react-three/drei" -import React, { useMemo, useRef, useState } from "react" +import React, { useMemo, useState } from "react" import { Euler, Group, Vector3 } from "three" import { describeMeasurement } from "pbHelpers/MeasurementDescriber" import { quack } from "protobuf-ts/quack" import { useGlobalState } from "providers" import { pond } from "protobuf-ts/pond" import Ring, { RingGeometry } from "3dModels/Shapes/3D/Ring" - -export interface NodeData { - radius: number - position: Vector3 - celcius: number - humidity?: number - moisture?: number - topNode?: boolean - excluded?: boolean - inGrain?: boolean -} +// import { Color } from "three"; +import { NodeData } from "../../Data/BuildNodeData" +import { tempToColour } from "bin/3dView/utils/tempToColour" interface Props { node: NodeData @@ -36,6 +28,13 @@ export default function CableNode(props: Props){ const ROW_HEIGHT = 35; const PADDING = 20; + //node colour varables for adjusting lighness and saturation for the thresholds + // const colourFade = 20 //this number is the temp diff above or below the thresholds that the node will reach its maximum red or blue intensity + // const minimumLightness = 0.3 //the minimum brightness of the colour, to low would approach black + // const lightnessRange = 0.2 //how much can the node brighten, to high will allow the node to turn white + // const minimumSaturation = 0.7 + // const saturationRange = 0.8 + useFrame(() => { //use the cameras distance to the center of the bin const distance = camera.position.distanceTo(binCenter) @@ -64,22 +63,68 @@ export default function CableNode(props: Props){ return temp } - const nodeColour = () => { - if (!node.inGrain) return "white"; + // const nodeColour = () => { + // if (!node.inGrain) return "white"; + // if (node.excluded) return "grey"; - const lower = lowerThreshold; - const upper = upperThreshold; + // const lower = lowerThreshold; + // const upper = upperThreshold; + // const temp = node.celcius; - if (lower !== undefined && node.celcius < lower) { - return "#3399ff"; // blue - } + // const GREEN = new Color("#52c41a"); + // const BLUE = new Color("#3399ff"); + // const RED = new Color("#ff4d4f"); - if (upper !== undefined && node.celcius > upper) { - return "#ff4d4f"; // red - } + // if (temp >= lower && temp <= upper) { + // return GREEN.getStyle(); + // } - return "#52c41a"; // green - }; + // // shared helper logic concept: + // const getCloseness = (distance: number) => { + // const normalized = Math.min(1, distance / colourFade); + // return 1 - normalized; // 1 = near threshold, 0 = far + // }; + + // const color = new Color(); + + // // BELOW lower (blue) + // if (temp < lower) { + // const distance = lower - temp; + // const closeness = getCloseness(distance); + + // const hsl = { h: 0, s: 1, l: 1 }; + // BLUE.getHSL(hsl); + + // const lightness = (lightnessRange * closeness) + minimumLightness; + + // // 🔥 NEW: saturation as second intensity layer + // const saturation = (saturationRange * (1 - closeness)) + minimumSaturation; + + // color.setHSL(hsl.h, saturation, lightness); + + // return color.getStyle(); + // } + + // // ABOVE upper (red) + // if (temp > upper) { + // const distance = temp - upper; + // const closeness = getCloseness(distance); + + // const hsl = { h: 0, s: 1, l: 1 }; + // RED.getHSL(hsl); + + // const lightness = (lightnessRange * closeness) + minimumLightness; + + // // 🔥 NEW: saturation as second intensity layer + // const saturation = (saturationRange * (1 - closeness)) + minimumSaturation; + + // color.setHSL(hsl.h, saturation, lightness); + + // return color.getStyle(); + // } + + // return GREEN.getStyle(); + // }; const rows = useMemo(() => { const r: { text: string; color: string }[] = []; @@ -153,7 +198,7 @@ export default function CableNode(props: Props){ {(!node.inGrain || !showLabel) && - + {node.topNode && ( )} diff --git a/src/bin/3dView/Systems/Cables/GrainCable.tsx b/src/bin/3dView/Systems/Cables/GrainCable.tsx new file mode 100644 index 0000000..44c83ff --- /dev/null +++ b/src/bin/3dView/Systems/Cables/GrainCable.tsx @@ -0,0 +1,47 @@ +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 + binCenter: Vector3 +} +export default function GrainCable(props: Props) { + const {cable, nodes, bin, binCenter} = 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 ( + + + {nodes.map((node, i) => ( + + ))} + + ) +} \ No newline at end of file diff --git a/src/bin/3dView/Systems/Heatmap/Heatmap.tsx b/src/bin/3dView/Systems/Heatmap/Heatmap.tsx new file mode 100644 index 0000000..0434581 --- /dev/null +++ b/src/bin/3dView/Systems/Heatmap/Heatmap.tsx @@ -0,0 +1,16 @@ +import { NodeData } from "bin/3dView/Data/BuildNodeData"; +import { Bin } from "models"; + +interface Props{ + bin: Bin + nodes: NodeData +} + +export default function Heatmap(props: Props){ + const {bin, nodes} = props + + return ( + <> + + ) +} \ No newline at end of file diff --git a/src/bin/3dView/BinInventory/GrainFillFlat.tsx b/src/bin/3dView/Systems/Inventory/GrainFillFlat.tsx similarity index 100% rename from src/bin/3dView/BinInventory/GrainFillFlat.tsx rename to src/bin/3dView/Systems/Inventory/GrainFillFlat.tsx diff --git a/src/bin/3dView/utils/tempToColour.ts b/src/bin/3dView/utils/tempToColour.ts new file mode 100644 index 0000000..c2c6d79 --- /dev/null +++ b/src/bin/3dView/utils/tempToColour.ts @@ -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"); + +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; +} \ No newline at end of file diff --git a/src/pages/Bin.tsx b/src/pages/Bin.tsx index 9a938bc..78e6c38 100644 --- a/src/pages/Bin.tsx +++ b/src/pages/Bin.tsx @@ -66,7 +66,7 @@ import ButtonGroup from "common/ButtonGroup"; import BinTransactions from "bin/BinTransactions"; import { CO2 } from "models/CO2"; import ObjectInteractions from "objects/objectInteractions/ObjectInteractions"; -import Bin3dView from "bin/3dView/Bin3dView"; +import Bin3dView from "bin/3dView/Scene/Bin3dView"; interface TabPanelProps { children?: React.ReactNode; From 239e69cfcbeb5672a89f20bbe9870fcd625e15cb Mon Sep 17 00:00:00 2001 From: csawatzky Date: Thu, 16 Apr 2026 16:29:11 -0600 Subject: [PATCH 040/146] hotfix to adjust the graphs clamp on the yAxis --- src/gate/GateDeltaTempGraph.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gate/GateDeltaTempGraph.tsx b/src/gate/GateDeltaTempGraph.tsx index 4b498dd..313cb05 100644 --- a/src/gate/GateDeltaTempGraph.tsx +++ b/src/gate/GateDeltaTempGraph.tsx @@ -211,8 +211,8 @@ export default function GateDeltaTempGraph(props: Props){ /> Date: Thu, 16 Apr 2026 16:51:43 -0600 Subject: [PATCH 041/146] tweaking it again --- src/gate/GateDeltaTempGraph.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gate/GateDeltaTempGraph.tsx b/src/gate/GateDeltaTempGraph.tsx index 313cb05..b5baef1 100644 --- a/src/gate/GateDeltaTempGraph.tsx +++ b/src/gate/GateDeltaTempGraph.tsx @@ -211,8 +211,8 @@ export default function GateDeltaTempGraph(props: Props){ /> Date: Tue, 21 Apr 2026 12:24:04 -0600 Subject: [PATCH 042/146] 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 --- src/3dModels/Shapes/BaseMesh.tsx | 6 +- src/bin/3dView/BinParts/BinShell.tsx | 4 +- src/bin/3dView/Data/BuildNodeData.ts | 13 +- src/bin/3dView/Scene/Bin3dView.tsx | 5 +- src/bin/3dView/Systems/Cables/CableNode.tsx | 18 +- .../3dView/Systems/Heatmap/HeatMapAlpha.tsx | 238 ++++++++++++++++++ src/bin/3dView/Systems/Heatmap/Heatmap.tsx | 16 -- .../3dView/Systems/Heatmap/NodePointCloud.tsx | 215 ++++++++++++++++ src/bin/3dView/utils/tempToColour.ts | 4 +- 9 files changed, 493 insertions(+), 26 deletions(-) create mode 100644 src/bin/3dView/Systems/Heatmap/HeatMapAlpha.tsx delete mode 100644 src/bin/3dView/Systems/Heatmap/Heatmap.tsx create mode 100644 src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx diff --git a/src/3dModels/Shapes/BaseMesh.tsx b/src/3dModels/Shapes/BaseMesh.tsx index d83716a..00e7ad2 100644 --- a/src/3dModels/Shapes/BaseMesh.tsx +++ b/src/3dModels/Shapes/BaseMesh.tsx @@ -14,6 +14,7 @@ export interface BaseMeshProps { materialProps?: Partial; materialOverride?: React.ReactNode; side?: Side + depthWrite?: boolean } export default function BaseMesh(props: BaseMeshProps) { @@ -30,7 +31,8 @@ export default function BaseMesh(props: BaseMeshProps) { materialType, materialProps, materialOverride, - side + side, + depthWrite = true } = props; const isTransparent = opacity < 1; @@ -43,7 +45,7 @@ export default function BaseMesh(props: BaseMeshProps) { color={colour} transparent={isTransparent} opacity={opacity} - depthWrite={!isTransparent} + depthWrite={depthWrite} side={side} {...materialProps} /> diff --git a/src/bin/3dView/BinParts/BinShell.tsx b/src/bin/3dView/BinParts/BinShell.tsx index d259bb7..fb40eb0 100644 --- a/src/bin/3dView/BinParts/BinShell.tsx +++ b/src/bin/3dView/BinParts/BinShell.tsx @@ -84,7 +84,7 @@ export default function BinShell(props: Props) { wireframe= {wireframe} metalness={binMetalness} position={roofPosition} - roughness={binRoughness} + roughness={binRoughness} side={2} opacity={binOpacity}/> {/* bin sidewall - cylinder (open ended for the roof and bottom)*/} @@ -93,7 +93,7 @@ export default function BinShell(props: Props) { colour={binBodyColour} wireframe= {wireframe} metalness={binMetalness} - roughness={binRoughness} + roughness={binRoughness} opacity={binOpacity}/> {/* bin bottom - cone -OR- circle depending on the bins shape*/} {hopperHeight !== undefined && hopperHeight > 0 ? diff --git a/src/bin/3dView/Data/BuildNodeData.ts b/src/bin/3dView/Data/BuildNodeData.ts index de14d23..0cb0c4f 100644 --- a/src/bin/3dView/Data/BuildNodeData.ts +++ b/src/bin/3dView/Data/BuildNodeData.ts @@ -33,17 +33,28 @@ export function BuildNodeData(cables: CableData[]): NodeData[] { 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 + //this is for testing to force a single node to a specific temp + // if (i === 0) { + // t = 30 + // } + // if (i === 1) { + // t = -30 + // } + + nodeData.push({ cableIndex: cableIndex, nodeIndex: i, radius: nodeRadius, position: new Vector3(cable.bottomPosition.x, nodeY, cable.bottomPosition.z), - celcius: celcius, + celcius: t, humidity: humidity, moisture: moisture, topNode: grainCable.topNode === i+1, //top node tracking starts at 1 not 0 so add one to the index diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index 5be25d8..8c0c377 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -8,6 +8,8 @@ import { Vector3 } from "three"; import { useMemo } from "react"; import { BuildCableData } from "../Data/BuildCableData"; import { BuildNodeData } from "../Data/BuildNodeData"; +import Heatmap from "../Systems/Heatmap/HeatMapAlpha"; +import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; interface Props { /** @@ -43,7 +45,7 @@ export default function Bin3dView(props: Props){ radialSegments={20} binMetalness={0.5} binRoughness={0.7} - binOpacity={0.5} + binOpacity={0.2} diameter={bin.diameter()} sidewallHeight={bin.sidewallHeight()} roofHeight={bin.roofHeight()} @@ -62,6 +64,7 @@ export default function Bin3dView(props: Props){ )} {/* cables */} + {/* lighting */} diff --git a/src/bin/3dView/Systems/Cables/CableNode.tsx b/src/bin/3dView/Systems/Cables/CableNode.tsx index b0b7307..a5c410f 100644 --- a/src/bin/3dView/Systems/Cables/CableNode.tsx +++ b/src/bin/3dView/Systems/Cables/CableNode.tsx @@ -10,7 +10,7 @@ import { pond } from "protobuf-ts/pond" import Ring, { RingGeometry } from "3dModels/Shapes/3D/Ring" // import { Color } from "three"; import { NodeData } from "../../Data/BuildNodeData" -import { tempToColour } from "bin/3dView/utils/tempToColour" +import { TempToColour } from "bin/3dView/utils/tempToColour" interface Props { node: NodeData @@ -193,12 +193,26 @@ export default function CableNode(props: Props){ return totalHeight / 2 - (index * ROW_HEIGHT) - ROW_HEIGHT / 2; }; + const nodeColour = () => { + if (node.excluded){ + return "grey" + } + if (!node.inGrain){ + return "white" + } + let c = TempToColour(node, lowerThreshold, upperThreshold, "node") + if (c !== null){ + return c.getStyle() + } + return "white" + } + return ( {(!node.inGrain || !showLabel) && - + {node.topNode && ( )} diff --git a/src/bin/3dView/Systems/Heatmap/HeatMapAlpha.tsx b/src/bin/3dView/Systems/Heatmap/HeatMapAlpha.tsx new file mode 100644 index 0000000..65fa1c1 --- /dev/null +++ b/src/bin/3dView/Systems/Heatmap/HeatMapAlpha.tsx @@ -0,0 +1,238 @@ +import { NodeData } from "bin/3dView/Data/BuildNodeData"; +import { colourFade, TempToColour } from "bin/3dView/utils/tempToColour"; +import { Bin } from "models"; +import { pond } from "protobuf-ts/pond"; +import { useMemo } from "react"; +import { AdditiveBlending, Vector3 } from "three"; + +interface Props{ + bin: Bin + nodes: NodeData[] +} + +/** + * this is a work in progress, the heatmap generated may not be accurate so avoid using this component for now + * @param props + * @returns + */ +export default function Heatmap(props: Props){ + const {bin, nodes} = props + //the steps that control how many 'layers' the heatmap will generate for each direction + const radialSteps = 18; // number of points across the diameter of the pin + const heightSteps = 20; // number of points up the side of the bin + const angleSteps = 40; // number of points around the circumfrence of the bin + + const getHeatIntensity = ( + temp: number, + lower: number, + upper: number, + fade: number + ) => { + if (temp >= lower && temp <= upper) return 0; + + const distance = + temp < lower ? lower - temp : temp - upper; + + // clamp 0 → 1 + return Math.min(1, distance / fade); + } + + const getTemperatureAtPoint = ( + point: Vector3, + nodes: NodeData[] + ) => { + let totalWeight = 0; + let weightedTemp = 0; + + //not sure if we should use a hard coded value or use a percentage of the bins diameter + //const maxDistance = 450; //tweak this (cm), it is the max distance that a node can influence the heatmap points + const maxDistance = bin.diameter() * 0.25; + + nodes.forEach(node => { + if (!node.inGrain || node.excluded) return; + + const distance = point.distanceTo(node.position); + + const intensity = getHeatIntensity( + node.celcius, + bin.lowerTempThreshold(), + bin.upperTempThreshold(), + colourFade + ); + + const nodeMaxDistance = maxDistance * (0.5 + intensity); + // tweakable: 0.5–1.5 range + + if (distance > nodeMaxDistance) return; + + const weight = 1 / (distance * distance + 1); + + totalWeight += weight; + weightedTemp += node.celcius * weight; + }); + + if (totalWeight === 0) return null; + + return weightedTemp / totalWeight; + } + + //this gets the highest top node to prevent points from being rendered above it + //we could in the future us the multple top nodes to clamp within a cloumn around that cable which would give us a more realistic grain area + 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]); + + const samplePoints = useMemo(() => { + const points = []; + + const radius = bin.diameter() / 2; + const height = bin.sidewallHeight(); + + + + for (let yStep = 0; yStep < heightSteps; yStep++) { + const y = -height / 2 + (yStep / heightSteps) * height; + if (y > maxGrainY) continue; + + for (let rStep = 0; rStep < radialSteps; rStep++) { + // const r = (rStep / radialSteps) * radius; + const r = Math.sqrt(rStep / radialSteps) * radius; + + for (let aStep = 0; aStep < angleSteps; aStep++) { + const angle = (aStep / angleSteps) * Math.PI * 2; + + const x = Math.cos(angle) * r; + const z = Math.sin(angle) * r; + + const position = new Vector3(x, y, z); + + const temp = getTemperatureAtPoint(position, nodes); + + points.push({ + position, + temp + }); + } + } + } + + return points; + }, [bin, nodes]); + + const { positions, colors, alphas } = useMemo(() => { + const positions: number[] = []; + const colors: number[] = []; + const alphas: number[] = []; + + samplePoints.forEach((p) => { + if (p.temp === null) return; + const intensity = getHeatIntensity( + p.temp, + bin.lowerTempThreshold(), + bin.upperTempThreshold(), + colourFade + ); + const alpha = Math.pow(intensity, 2); // try 2 → 3 for stronger fade + + const virtualNode: NodeData = { + cableIndex: -1, + nodeIndex: -1, + radius: 0, + position: p.position, + celcius: p.temp, + inGrain: true, + excluded: false + }; + + const color = TempToColour( + virtualNode, + bin.lowerTempThreshold(), + bin.upperTempThreshold(), + "heatmap" + ); + + if (!color) return; + + // position + positions.push(p.position.x, p.position.y, p.position.z); + + // color (normalized 0–1) + colors.push(color.r, color.g, color.b); + alphas.push(alpha) + + }); + + return { + positions: new Float32Array(positions), + colors: new Float32Array(colors), + alphas: new Float32Array(alphas) + }; + }, [samplePoints, bin]); + + return ( + + + + + + + + + + ); +} \ No newline at end of file diff --git a/src/bin/3dView/Systems/Heatmap/Heatmap.tsx b/src/bin/3dView/Systems/Heatmap/Heatmap.tsx deleted file mode 100644 index 0434581..0000000 --- a/src/bin/3dView/Systems/Heatmap/Heatmap.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { NodeData } from "bin/3dView/Data/BuildNodeData"; -import { Bin } from "models"; - -interface Props{ - bin: Bin - nodes: NodeData -} - -export default function Heatmap(props: Props){ - const {bin, nodes} = props - - return ( - <> - - ) -} \ No newline at end of file diff --git a/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx b/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx new file mode 100644 index 0000000..65768de --- /dev/null +++ b/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx @@ -0,0 +1,215 @@ +import { NodeData } from "bin/3dView/Data/BuildNodeData"; +import { colourFade } from "bin/3dView/utils/tempToColour"; +import { Bin } from "models"; +import { useMemo } from "react"; +import { AdditiveBlending, CanvasTexture } 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.2; + const INTENSITY_BRIGHTNESS_MULT = 0.5; + + const OPACITY = 0.7; + const POINT_SIZE = 1; + + const getHeatIntensity = ( + temp: number, + lower: number, + upper: number, + fade: number + ) => { + if (temp >= lower && temp <= upper) return 0; + + const distance = + temp < lower ? lower - temp : temp - upper; + + return Math.min(1, distance / fade); + }; + + // ----------------------------- + // 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]); + + // ----------------------------- + // BUILD POINT CLOUD FROM NODES + // ----------------------------- + const { positions, colors } = useMemo(() => { + const positions: number[] = []; + const colors: number[] = []; + + const baseRadius = bin.diameter() * BASE_RADIUS_FACTOR; + + nodes.forEach((node) => { + if (!node.inGrain || node.excluded) return; + + const intensity = getHeatIntensity( + node.celcius, + bin.lowerTempThreshold(), + bin.upperTempThreshold(), + colourFade + ); + + // skip normal nodes + if (intensity === 0) return; + + const isHot = node.celcius > bin.upperTempThreshold(); + + // expand cloud based on severity + const radius = baseRadius * (0.5 + intensity); + + //these control the number of points along that axis + const densityMultiplier = INTENSITY_DENSITY_BASE + intensity * INTENSITY_DENSITY_MULT; + const radialSteps = Math.floor(RADIAL_BASE * densityMultiplier);//points along the radius to the edge + const thetaSteps = Math.floor(THETA_BASE * densityMultiplier);//points along the azimuth (horizontal angle) + const phiSteps = Math.floor(PHI_BASE * densityMultiplier);//points along the vertical angle + + const radiusLimit = bin.diameter() / 2; + const minY = -bin.sidewallHeight() / 2; + + for (let rStep = 0; rStep < radialSteps; rStep++) { + // sqrt for even density + const r = Math.sqrt(rStep / radialSteps) * radius; + + for (let pStep = 0; pStep < phiSteps; pStep++) { + // avoid poles clustering by not hitting exact 0/PI + 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.position.x + + r * Math.sin(phi) * Math.cos(theta); + + const y = + node.position.y + + r * Math.cos(phi); + + const z = + node.position.z + + r * Math.sin(phi) * Math.sin(theta); + + // cylindrical bin bounds + const distXZ = Math.sqrt(x * x + z * z); + if (distXZ > radiusLimit) continue; + if (y > maxGrainY || y < minY) continue; + + positions.push(x, y, z); + + const falloff = 1 - r / radius; + + // shape of the curve + const spatialFalloff = Math.pow(falloff, 2); + + const adjustedFalloff = + MIN_EDGE_INTENSITY + (1 - MIN_EDGE_INTENSITY) * spatialFalloff; + + const intensityCurve = intensity * intensity; // quadratic easing + const baseBrightness = + BASE_BRIGHTNESS + INTENSITY_BRIGHTNESS_MULT * intensityCurve; + + const brightness = adjustedFalloff * baseBrightness; + + if (isHot) { + colors.push(brightness, 0, 0); + } else { + colors.push(0, 0, brightness); + } + } + } + } + }); + + return { + positions: new Float32Array(positions), + colors: new Float32Array(colors), + }; + }, [nodes, bin, maxGrainY]); + + 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 ( + + + + + + + + + ); +} \ No newline at end of file diff --git a/src/bin/3dView/utils/tempToColour.ts b/src/bin/3dView/utils/tempToColour.ts index c2c6d79..b8317df 100644 --- a/src/bin/3dView/utils/tempToColour.ts +++ b/src/bin/3dView/utils/tempToColour.ts @@ -7,7 +7,7 @@ const GREEN = new Color("#52c41a"); const BLUE = new Color("#3399ff"); const RED = new Color("#ff4d4f"); -const colourFade = 20; +export const colourFade = 20; const minimumLightness = 0.3; const lightnessRange = 0.2; const minimumSaturation = 0.7; @@ -30,7 +30,7 @@ const getVisualIntensity = (distance: number, mode: ColourMode) => { } }; -export function tempToColour( +export function TempToColour( node: NodeData, lower: number, upper: number, From f548559a2304c7cc804cfaeb1525169e5b673560 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 21 Apr 2026 14:49:10 -0600 Subject: [PATCH 043/146] package updates --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 2b0371b..73cc5a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11663,7 +11663,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#9c0f668d4a56b8216dd71a44c3110a818aaf3541", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#0aa61453c8641cf5ede545949a5f2f89f6c393ec", "dependencies": { "protobufjs": "^6.8.8" } From 90ad5f8b120c8997fc65e00447a263033a801db6 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 21 Apr 2026 15:38:14 -0600 Subject: [PATCH 044/146] new component type --- package-lock.json | 4 +- package.json | 2 +- src/bin/BinVisualizerV2.tsx | 2 +- src/pbHelpers/ComponentType.tsx | 6 +- .../ComponentTypes/AnalogPressure.ts | 88 +++++++++++++++++++ src/pbHelpers/ComponentTypes/index.ts | 1 + src/products/MiVent/MiVentAvailability.ts | 4 +- 7 files changed, 100 insertions(+), 7 deletions(-) create mode 100644 src/pbHelpers/ComponentTypes/AnalogPressure.ts diff --git a/package-lock.json b/package-lock.json index 2cd53e8..0e54b33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#dev", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", @@ -11205,7 +11205,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#9c0f668d4a56b8216dd71a44c3110a818aaf3541", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#b44f5fa7183d9565e9fd9e7a9ac756114401ddfd", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/package.json b/package.json index 90ac71b..ba69f2e 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#dev", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", diff --git a/src/bin/BinVisualizerV2.tsx b/src/bin/BinVisualizerV2.tsx index f6f9c1d..437f09d 100644 --- a/src/bin/BinVisualizerV2.tsx +++ b/src/bin/BinVisualizerV2.tsx @@ -1539,7 +1539,7 @@ export default function BinVisualizer(props: Props) { ) : ( - + {fullScreenHandler.enter()}}> )} diff --git a/src/pbHelpers/ComponentType.tsx b/src/pbHelpers/ComponentType.tsx index 047c852..c31154b 100644 --- a/src/pbHelpers/ComponentType.tsx +++ b/src/pbHelpers/ComponentType.tsx @@ -39,7 +39,8 @@ import { Voltage, VPD, Weight, - CapacitorCable + CapacitorCable, + AnalogPressure } from "pbHelpers/ComponentTypes"; //import { multilineGrainCableData } from "pbHelpers/ComponentTypes/GrainCable"; //import { multilineCapCableData } from "./ComponentTypes/CapacitorCable"; @@ -96,7 +97,8 @@ const COMPONENT_TYPE_MAP = new Map([ [quack.ComponentType.COMPONENT_TYPE_SEN5X, Sen5x], [quack.ComponentType.COMPONENT_TYPE_VIBRATION_CHAIN, VibrationCable], [quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE, dragerGasDongle], - [quack.ComponentType.COMPONENT_TYPE_AIRFLOW, Airflow] + [quack.ComponentType.COMPONENT_TYPE_AIRFLOW, Airflow], + [quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE, AnalogPressure] ]); export interface Subtype { diff --git a/src/pbHelpers/ComponentTypes/AnalogPressure.ts b/src/pbHelpers/ComponentTypes/AnalogPressure.ts new file mode 100644 index 0000000..f4d3936 --- /dev/null +++ b/src/pbHelpers/ComponentTypes/AnalogPressure.ts @@ -0,0 +1,88 @@ +import { + ComponentTypeExtension, + Summary, + Subtype, + simpleMeasurements, + simpleSummaries, + unitMeasurementSummaries, + AreaChartData, + GraphFilters, + simpleAreaChartData, + LineChartData, + simpleLineChartData + } from "pbHelpers/ComponentType"; + import PressureDarkIcon from "assets/components/pressureDark.png"; + import PressureLightIcon from "assets/components/pressureLight.png"; + import { quack } from "protobuf-ts/quack"; + import { describeMeasurement } from "pbHelpers/MeasurementDescriber"; + import { convertedUnitMeasurement } from "models/UnitMeasurement"; + import { pond } from "protobuf-ts/pond"; + + export function AnalogPressure(subtype: number = 0): ComponentTypeExtension { + let pressure = describeMeasurement( + quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE, + quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE, + subtype + ); + let voltage = describeMeasurement( + quack.MeasurementType.MEASUREMENT_TYPE_VOLTAGE, + quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE, + subtype + ) + let addressTypes = [quack.AddressType.ADDRESS_TYPE_I2C]; + return { + type: quack.ComponentType.COMPONENT_TYPE_PRESSURE, + subtypes: [ + { + key: quack.AnalogPressureSubtype.ANALOG_PRESSURE_SUBTYPE_PMC21, + value: "ANALOG_PRESSURE_SUBTYPE_PMC21", + friendlyName: "PMC 21" + } as Subtype + ], + friendlyName: "Analog Pressure", + description: "Measures the atmospheric pressure or absolute pressure", + isController: false, + isSource: true, + isCalibratable: true, + hasFan: true, + addressTypes: addressTypes, + interactionResultTypes: [], + states: [], + measurements: simpleMeasurements(pressure, voltage), + measurementSummary: async function(measurement: quack.Measurement): Promise> { + return simpleSummaries(measurement, pressure); + }, + unitMeasurementSummary: ( + measurements: convertedUnitMeasurement, + ): Summary[] => { + return unitMeasurementSummaries( + measurements, + quack.ComponentType.COMPONENT_TYPE_PRESSURE, + subtype, + ); + }, + areaChartData: ( + measurement: pond.UnitMeasurementsForComponent, + smoothingAverages?: number, + filters?: GraphFilters + ): AreaChartData => { + return simpleAreaChartData(measurement, smoothingAverages, filters); + }, + lineChartData: ( + measurement: pond.UnitMeasurementsForComponent, + smoothingAverages?: number, + filters?: GraphFilters + ): LineChartData => { + return simpleLineChartData( + quack.ComponentType.COMPONENT_TYPE_PRESSURE, + measurement, + smoothingAverages, + filters + ); + }, + minMeasurementPeriodMs: 1000, + icon: (theme?: "light" | "dark"): string | undefined => { + return theme === "light" ? PressureDarkIcon : PressureLightIcon; + } + }; + } \ No newline at end of file diff --git a/src/pbHelpers/ComponentTypes/index.ts b/src/pbHelpers/ComponentTypes/index.ts index 349bbbe..39b1560 100644 --- a/src/pbHelpers/ComponentTypes/index.ts +++ b/src/pbHelpers/ComponentTypes/index.ts @@ -29,3 +29,4 @@ export * from "./Voltage"; export * from "./VPD"; export * from "./Weight"; export * from "./CapacitorCable"; +export * from "./AnalogPressure"; diff --git a/src/products/MiVent/MiVentAvailability.ts b/src/products/MiVent/MiVentAvailability.ts index 0108806..87d0db2 100644 --- a/src/products/MiVent/MiVentAvailability.ts +++ b/src/products/MiVent/MiVentAvailability.ts @@ -10,6 +10,7 @@ const MiVentV1Pins: ConfigurablePin[] = [ { address: 1024, label: "C2" } ]; +//no longer in development so if you are adding new component types DO NOT add the I2C address here because the firmware of the device will not support it export const MiVentV1Availability: DeviceAvailabilityMap = new Map< quack.AddressType, DevicePositions @@ -53,7 +54,8 @@ export const MiVentV2Availability: DeviceAvailabilityMap = new Map< [quack.ComponentType.COMPONENT_TYPE_DHT, [0x40]], [quack.ComponentType.COMPONENT_TYPE_SEN5X, [0x69]], [quack.ComponentType.COMPONENT_TYPE_AIRFLOW, [0x6c]], - [quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE, [0x6d]] + [quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE, [0x6d]], + [quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE, [0x6e]] ]) ], From c0a3e23d678b7e8e89d635ee8d528848ebde7bb9 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 21 Apr 2026 16:13:59 -0600 Subject: [PATCH 045/146] missed the device availability stuff --- src/pbHelpers/DeviceAvailability.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pbHelpers/DeviceAvailability.ts b/src/pbHelpers/DeviceAvailability.ts index 972c2e2..da39022 100644 --- a/src/pbHelpers/DeviceAvailability.ts +++ b/src/pbHelpers/DeviceAvailability.ts @@ -64,7 +64,8 @@ const DefaultAvailability: DeviceAvailabilityMap = new Map Date: Wed, 22 Apr 2026 09:13:30 -0600 Subject: [PATCH 046/146] commenting out test data --- src/bin/3dView/Data/BuildNodeData.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bin/3dView/Data/BuildNodeData.ts b/src/bin/3dView/Data/BuildNodeData.ts index 0cb0c4f..bf6fad3 100644 --- a/src/bin/3dView/Data/BuildNodeData.ts +++ b/src/bin/3dView/Data/BuildNodeData.ts @@ -41,10 +41,10 @@ export function BuildNodeData(cables: CableData[]): NodeData[] { let t = celcius //this is for testing to force a single node to a specific temp // if (i === 0) { - // t = 30 + // t = -10 // } // if (i === 1) { - // t = -30 + // t = 30 // } From f96d1184b17871a360a6fc8725470c17ba67cad1 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 22 Apr 2026 10:38:51 -0600 Subject: [PATCH 047/146] changing date range when zooming airflow --- package-lock.json | 2 +- src/gate/GateDeltaTempGraph.tsx | 1 - src/gate/GateFlowGraph.tsx | 13 +++++++---- src/gate/GateGraphs.tsx | 40 ++++++++++++++++++++++----------- 4 files changed, 37 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2cd53e8..9ca1dff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11205,7 +11205,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#9c0f668d4a56b8216dd71a44c3110a818aaf3541", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#0aa61453c8641cf5ede545949a5f2f89f6c393ec", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/src/gate/GateDeltaTempGraph.tsx b/src/gate/GateDeltaTempGraph.tsx index b5baef1..6cd8a08 100644 --- a/src/gate/GateDeltaTempGraph.tsx +++ b/src/gate/GateDeltaTempGraph.tsx @@ -110,7 +110,6 @@ export default function GateDeltaTempGraph(props: Props){ end.toISOString(), 500 ).then(resp => { - console.log(resp) if(resp.data.measurements){ let tempCompMeasurements = resp.data.measurements.map(um => UnitMeasurement.any(um, user)); //if there is an ambient component, then treat the temp component like a single sensor and not a chain, and use the ambient measurements as the inlet diff --git a/src/gate/GateFlowGraph.tsx b/src/gate/GateFlowGraph.tsx index b9a85b1..2e254e7 100644 --- a/src/gate/GateFlowGraph.tsx +++ b/src/gate/GateFlowGraph.tsx @@ -15,7 +15,7 @@ import { Gate } from "models/Gate"; import moment, { Moment } from "moment"; import { pond } from "protobuf-ts/pond"; import { useGateAPI, useGlobalState } from "providers"; -import React, { useEffect, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; interface Props { gate: Gate; @@ -74,12 +74,13 @@ export default function GateFlowGraph(props: Props) { const eventThreshold = 5; //the threshold that if crossed from one measurement to the next during a flow event will end the current flow event and start a new one //const idleFlow = 3.5; //the mass air flow that must be crossed in order to start a flow event, anything below this will not start an event but must go below this to end an event const [idleFlow, setIdleFlow] = useState(3.5) - - useEffect(() => { + + const loadData = useCallback(()=>{ if (loadingChartData) return; if (ambient && pressureComponent) { let gateIdle = idleFlow if(gate.settings.idleFlow){ + setIdleFlow(gate.settings.idleFlow) gateIdle = gate.settings.idleFlow } @@ -146,7 +147,11 @@ export default function GateFlowGraph(props: Props) { setRecent(recent); }); } - }, [gateAPI, gate, ambient, pressureComponent, start, end, device, as]); // eslint-disable-line react-hooks/exhaustive-deps + },[gateAPI, gate, ambient, pressureComponent, start, end, device, as]) + + useEffect(() => { + loadData() + }, [loadData]); // eslint-disable-line react-hooks/exhaustive-deps const loadFlowEvents = () => { if (ambient && pressureComponent) { diff --git a/src/gate/GateGraphs.tsx b/src/gate/GateGraphs.tsx index 5a3a039..9858b0c 100644 --- a/src/gate/GateGraphs.tsx +++ b/src/gate/GateGraphs.tsx @@ -102,17 +102,18 @@ export default function GateGraphs(props: Props) { }, [gate, endDate, gateAPI, startDate, as]); // eslint-disable-line react-hooks/exhaustive-deps const updateDateRange = (newStartDate: any, newEndDate: any) => { - let range = GetDefaultDateRange(); - range.start = newStartDate; - range.end = newEndDate; + // let range = GetDefaultDateRange(); + // range.start = newStartDate; + // range.end = newEndDate; setStartDate(newStartDate); setEndDate(newEndDate); }; - const zoomOut = () => { - setXDomain(["dataMin", "dataMax"]); - setZoomed(false); - }; + // const zoomOut = () => { + // setXDomain(["dataMin", "dataMax"]); + + // setZoomed(false); + // }; const lineGraph = ( unitMeasurement: UnitMeasurement, @@ -177,6 +178,7 @@ export default function GateGraphs(props: Props) { multiGraphZoom={domain => { setXDomain(domain); setZoomed(true); + //set new start and end here as well }} multiGraphZoomOut /> @@ -241,6 +243,7 @@ export default function GateGraphs(props: Props) { multiGraphZoom={domain => { setXDomain(domain); setZoomed(true); + //update the start and end here as well }} multiGraphZoomOut /> @@ -250,7 +253,6 @@ export default function GateGraphs(props: Props) { const graphHeader = (comp: Component) => { const componentIcon = GetComponentIcon(comp.settings.type, comp.settings.subtype, themeType); - console.log(comp) let measurement = cloneDeep(comp.status.measurement) let umArray = measurement.map(um => UnitMeasurement.create(um, user)) return ( @@ -360,13 +362,25 @@ export default function GateGraphs(props: Props) { // setPCAState(state); // }} multiGraphZoom={domain => { - setXDomain(domain); - setZoomed(true); + //need to update the dateRange here so that the graph re-loads data + if(domain[0] && !isNaN(parseInt(domain[0] as string))){ + setStartDate(moment(domain[0])) + } + if(domain[1] && !isNaN(parseInt(domain[1] as string))){ + setEndDate(moment(domain[1])) + } }} /> {/* add a new chart here for the delta temp over time */} {tempComp && - + } ); @@ -387,13 +401,13 @@ export default function GateGraphs(props: Props) { - {zoomed && ( + {/* {zoomed && ( - )} + )} */} {loading ? : showGraphs()} From 5e52e096a287b3d431e6198757a3b5647eafb48d Mon Sep 17 00:00:00 2001 From: Carter Date: Wed, 22 Apr 2026 14:27:58 -0600 Subject: [PATCH 048/146] build for local server and deployment script --- deploy.sh | 11 +++++++++++ docker-compose.local.yml | 18 ++++++++++++++++++ package.json | 1 + 3 files changed, 30 insertions(+) create mode 100644 deploy.sh create mode 100644 docker-compose.local.yml diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..cdcd400 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,11 @@ +# 1. Build +docker build -t webui:local -f pond/Dockerfile . + +# 2. Transfer image +docker save webui:local | ssh -i ~/.ssh/id_ed25519 carter@172.16.1.20 docker load + +# 3. SCP compose file +scp -i ~/.ssh/id_ed25519 docker-compose.local.yml carter@172.16.1.20:~/ + +# 4. Deploy +ssh -i ~/.ssh/id_ed25519 carter@172.16.1.20 "docker stack deploy -c docker-compose.local.yml webui" \ No newline at end of file diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..b8722cb --- /dev/null +++ b/docker-compose.local.yml @@ -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 \ No newline at end of file diff --git a/package.json b/package.json index 90ac71b..31aea89 100644 --- a/package.json +++ b/package.json @@ -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_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", "build:offline": "npx env-cmd offline,whitelabel npm run build", "test": "vitest" }, From 6565b854b6ed7c97f125eb775012aeecae7c26b5 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 22 Apr 2026 14:52:06 -0600 Subject: [PATCH 049/146] using the expiry time in the gate status for whehter its missing or not --- package-lock.json | 2 +- src/gate/GateDeltaTempGraph.tsx | 7 +++-- src/gate/GateFlowGraph.tsx | 11 -------- src/gate/GateGraphs.tsx | 48 +++++++++++++++------------------ src/gate/GateList.tsx | 7 ++++- 5 files changed, 33 insertions(+), 42 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9ca1dff..a8d3068 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11205,7 +11205,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#0aa61453c8641cf5ede545949a5f2f89f6c393ec", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#29d9765ce93c04ad70d795b80e9873fc2fc5f600", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/src/gate/GateDeltaTempGraph.tsx b/src/gate/GateDeltaTempGraph.tsx index 6cd8a08..d4a4465 100644 --- a/src/gate/GateDeltaTempGraph.tsx +++ b/src/gate/GateDeltaTempGraph.tsx @@ -20,6 +20,7 @@ interface Props { ambient?: Component start: Moment; end: Moment; + multiGraphZoom?: (domain: number[] | string[]) => void; } export default function GateDeltaTempGraph(props: Props){ @@ -28,7 +29,8 @@ export default function GateDeltaTempGraph(props: Props){ ambient, deviceID, start, - end + end, + multiGraphZoom } = props const [data, setData] = useState([]) const [{user}] = useGlobalState() @@ -216,7 +218,8 @@ export default function GateDeltaTempGraph(props: Props){ tooltipUnit={user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? " °F" : " °C"} yAxisLabel={"Delta T" + (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? " °F" : " °C")} colourAboveZero={{colour: warmingColour, label: "Heating"}} - colourBelowZero={{colour: coolingColour, label: "Cooling"}}/> + colourBelowZero={{colour: coolingColour, label: "Cooling"}} + multiGraphZoom={multiGraphZoom}/> ) diff --git a/src/gate/GateFlowGraph.tsx b/src/gate/GateFlowGraph.tsx index 2e254e7..458d377 100644 --- a/src/gate/GateFlowGraph.tsx +++ b/src/gate/GateFlowGraph.tsx @@ -25,7 +25,6 @@ interface Props { ambient?: string; newXDomain?: number[] | string[]; pressureComponent?: string; - // setPCAState: (state: boolean) => void; multiGraphZoom?: (domain: number[] | string[]) => void; } @@ -55,7 +54,6 @@ export default function GateFlowGraph(props: Props) { device, ambient, pressureComponent, - // setPCAState, start, end, newXDomain, @@ -132,15 +130,6 @@ export default function GateFlowGraph(props: Props) { } }); setRuntime(moment.duration(runtime)); - // let state = false; - // if ( - // data.length > 1 && - // data[data.length - 1].value > gate.lowerFlow() && - // data[data.length - 1].value < gate.upperFlow() - // ) { - // state = true; - // } - // setPCAState(state); } setFlowData(data); setLoadingChartData(false); diff --git a/src/gate/GateGraphs.tsx b/src/gate/GateGraphs.tsx index 9858b0c..319b137 100644 --- a/src/gate/GateGraphs.tsx +++ b/src/gate/GateGraphs.tsx @@ -101,19 +101,24 @@ export default function GateGraphs(props: Props) { }); }, [gate, endDate, gateAPI, startDate, as]); // eslint-disable-line react-hooks/exhaustive-deps - const updateDateRange = (newStartDate: any, newEndDate: any) => { - // let range = GetDefaultDateRange(); - // range.start = newStartDate; - // range.end = newEndDate; + const updateDateRange = (newStartDate: moment.Moment, newEndDate: moment.Moment) => { setStartDate(newStartDate); setEndDate(newEndDate); }; - // const zoomOut = () => { - // setXDomain(["dataMin", "dataMax"]); + const zoomGraphs = (domain: string[] | number[]) => { + setZoomed(true) + if((domain[0] && !isNaN(parseInt(domain[0] as string))) && (domain[1] && !isNaN(parseInt(domain[1] as string)))){ + updateDateRange(moment(domain[0]),moment(domain[1])) + } + } - // setZoomed(false); - // }; + const zoomOut = () => { + let d = GetDefaultDateRange() + setStartDate(d.start) + setEndDate(d.end) + setZoomed(false); + }; const lineGraph = ( unitMeasurement: UnitMeasurement, @@ -176,9 +181,7 @@ export default function GateGraphs(props: Props) { tooltip newXDomain={xDomain} multiGraphZoom={domain => { - setXDomain(domain); - setZoomed(true); - //set new start and end here as well + zoomGraphs(domain) }} multiGraphZoomOut /> @@ -241,9 +244,7 @@ export default function GateGraphs(props: Props) { tooltip newXDomain={xDomain} multiGraphZoom={domain => { - setXDomain(domain); - setZoomed(true); - //update the start and end here as well + zoomGraphs(domain) }} multiGraphZoomOut /> @@ -358,17 +359,8 @@ export default function GateGraphs(props: Props) { gate={gate} ambient={ambient} pressureComponent={pressure} - // setPCAState={(state: boolean) => { - // setPCAState(state); - // }} multiGraphZoom={domain => { - //need to update the dateRange here so that the graph re-loads data - if(domain[0] && !isNaN(parseInt(domain[0] as string))){ - setStartDate(moment(domain[0])) - } - if(domain[1] && !isNaN(parseInt(domain[1] as string))){ - setEndDate(moment(domain[1])) - } + zoomGraphs(domain) }} /> {/* add a new chart here for the delta temp over time */} @@ -379,7 +371,9 @@ export default function GateGraphs(props: Props) { end={endDate} tempComponent={tempComp} ambient={ambientComp} - + multiGraphZoom={domain => { + zoomGraphs(domain) + }} /> } @@ -401,13 +395,13 @@ export default function GateGraphs(props: Props) { - {/* {zoomed && ( + {zoomed && ( - )} */} + )} {loading ? : showGraphs()} diff --git a/src/gate/GateList.tsx b/src/gate/GateList.tsx index ba521cc..a2f4f77 100644 --- a/src/gate/GateList.tsx +++ b/src/gate/GateList.tsx @@ -272,7 +272,12 @@ export default function GateList(props: Props) { // cellStyle: {width: 100000}, // sortKey: "state", render: gate => { - const deviceStateHelper = getDeviceStateHelper(gate.status.pcaDeviceState); + let deviceStateHelper = getDeviceStateHelper(pond.DeviceState.DEVICE_STATE_UNKNOWN); + if(moment().isAfter(moment(gate.status.deviceStateExpires).add(1, 'hour'))){ + deviceStateHelper = getDeviceStateHelper(pond.DeviceState.DEVICE_STATE_MISSING) + }else{ + deviceStateHelper = getDeviceStateHelper(gate.status.pcaDeviceState) + } return ( {deviceStateHelper.icon} From 0701be2539fdf8e8bc7b1888ebb10c9e1187cff1 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Thu, 23 Apr 2026 13:03:32 -0600 Subject: [PATCH 050/146] added the fill system for cable controlled inventory --- src/bin/3dView/Data/BuildNodeData.ts | 28 +- src/bin/3dView/Scene/Bin3dView.tsx | 35 +- .../Systems/Inventory/GrainCableFill.tsx | 329 ++++++++++++++++++ 3 files changed, 366 insertions(+), 26 deletions(-) create mode 100644 src/bin/3dView/Systems/Inventory/GrainCableFill.tsx diff --git a/src/bin/3dView/Data/BuildNodeData.ts b/src/bin/3dView/Data/BuildNodeData.ts index bf6fad3..4196820 100644 --- a/src/bin/3dView/Data/BuildNodeData.ts +++ b/src/bin/3dView/Data/BuildNodeData.ts @@ -12,6 +12,12 @@ export interface NodeData { 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[] { @@ -31,23 +37,13 @@ export function BuildNodeData(cables: CableData[]): NodeData[] { const spacing = height / nodeCount; - - grainCable.celcius.forEach((celcius,i) => { - + 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 - //this is for testing to force a single node to a specific temp - // if (i === 0) { - // t = -10 - // } - // if (i === 1) { - // t = 30 - // } - - nodeData.push({ cableIndex: cableIndex, @@ -57,13 +53,11 @@ export function BuildNodeData(cables: CableData[]): NodeData[] { celcius: t, humidity: humidity, moisture: moisture, - topNode: grainCable.topNode === i+1, //top node tracking starts at 1 not 0 so add one to the index + topNode: grainCable.topNode === i + 1, excluded: grainCable.excludedNodes?.includes(i) ?? false, - inGrain: i+1 <= grainCable.topNode - // optional: (may want to add this to the node data later) - // normalizedHeight: (nodeY - bottomY) / height + inGrain: i + 1 <= grainCable.topNode || !grainCable.topNode, + nodeSpacing: spacing, }) - }) }) diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index 8c0c377..45fcdb6 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -8,8 +8,9 @@ import { Vector3 } from "three"; import { useMemo } from "react"; import { BuildCableData } from "../Data/BuildCableData"; import { BuildNodeData } from "../Data/BuildNodeData"; -import Heatmap from "../Systems/Heatmap/HeatMapAlpha"; import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; +import { pond } from "protobuf-ts/pond"; +import GrainCableFill from "../Systems/Inventory/GrainCableFill"; interface Props { /** @@ -37,6 +38,29 @@ export default function Bin3dView(props: Props){ const cableData = useMemo(() => BuildCableData(bin), [bin]); const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]); + const grainInventory = () => { + if(bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_AUTOMATIC || + bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_HYBRID_CABLE){ + return ( + + ) + }else if (fillPercent){ + + } + } + return ( @@ -54,14 +78,7 @@ export default function Bin3dView(props: Props){ /> {/* grain - cylinder*/} - {fillPercent !== undefined && ( - - )} + {grainInventory()} {/* cables */} diff --git a/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx b/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx new file mode 100644 index 0000000..0ae8a2e --- /dev/null +++ b/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx @@ -0,0 +1,329 @@ +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 (0–1) used when no top nodes are available. + * If undefined and no top nodes exist, nothing is rendered. + */ + fallbackFillPercent?: number; +} + +// 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 + } = props; + + const binRadius = diameter / 2; + // Slightly inset to avoid z-fighting with the shell + const grainRadius = binRadius * 0.98; + const grainColour = "#fff302"; + + // --- 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 && ( + + )} + {cylinderFillHeight > 0 && ( + + + + + )} + + ); + } + + // --- Render cable-driven surface --- + return ( + <> + {/* Interpolated grain surface */} + {surfaceGeometry && ( + + + + )} + + {/* Cylindrical body of grain below the surface down to the bin floor / hopper top */} + + + {/* Hopper fill — always full when grain surface exists above the floor */} + {hopperIsActive && ( + + )} + + ); +} \ No newline at end of file From f4d1167e4d3b5760d1bf680bbcd1d9e0cf555871 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Thu, 23 Apr 2026 15:56:59 -0600 Subject: [PATCH 051/146] added functions to return the node/cable clicked on all the way up to the parent --- src/3dModels/Shapes/BaseMesh.tsx | 14 +- src/bin/3dView/Scene/Bin3dView.tsx | 16 +- src/bin/3dView/Systems/Cables/BinCables.tsx | 17 +- src/bin/3dView/Systems/Cables/CableNode.tsx | 226 ++++++------------- src/bin/3dView/Systems/Cables/GrainCable.tsx | 5 +- 5 files changed, 113 insertions(+), 165 deletions(-) diff --git a/src/3dModels/Shapes/BaseMesh.tsx b/src/3dModels/Shapes/BaseMesh.tsx index 00e7ad2..d92601d 100644 --- a/src/3dModels/Shapes/BaseMesh.tsx +++ b/src/3dModels/Shapes/BaseMesh.tsx @@ -1,4 +1,5 @@ import { Euler, Vector3, BufferGeometry, MaterialParameters, Side } from "three"; +import { ThreeEvent } from "@react-three/fiber"; export interface BaseMeshProps { geometry: BufferGeometry; @@ -13,8 +14,9 @@ export interface BaseMeshProps { materialType?: "standard" | "basic"; materialProps?: Partial; materialOverride?: React.ReactNode; - side?: Side - depthWrite?: boolean + side?: Side; + depthWrite?: boolean; + onClick?: (event: ThreeEvent) => void; } export default function BaseMesh(props: BaseMeshProps) { @@ -32,10 +34,12 @@ export default function BaseMesh(props: BaseMeshProps) { materialProps, materialOverride, side, - depthWrite = true + depthWrite = true, + onClick, } = props; const isTransparent = opacity < 1; + const buildMaterial = () => { if (materialOverride) return materialOverride; switch (materialType) { @@ -50,7 +54,7 @@ export default function BaseMesh(props: BaseMeshProps) { {...materialProps} /> ); - + case "standard": default: return ( @@ -71,7 +75,7 @@ export default function BaseMesh(props: BaseMeshProps) { return ( {/* Main surface */} - + {buildMaterial()} diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index 45fcdb6..403dc8f 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -6,8 +6,8 @@ import GrainFillFlat from "../Systems/Inventory/GrainFillFlat"; import BinCables from "../Systems/Cables/BinCables"; import { Vector3 } from "three"; import { useMemo } from "react"; -import { BuildCableData } from "../Data/BuildCableData"; -import { BuildNodeData } from "../Data/BuildNodeData"; +import { BuildCableData, CableData } from "../Data/BuildCableData"; +import { BuildNodeData, NodeData } from "../Data/BuildNodeData"; import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; import { pond } from "protobuf-ts/pond"; import GrainCableFill from "../Systems/Inventory/GrainCableFill"; @@ -27,13 +27,14 @@ interface Props { * optional: the percent of the bin to fill using a level top, this will be used with manual and lidar controls */ fillPercent?: number + nodeClick?: (node: NodeData, cable: CableData) => void } export default function Bin3dView(props: Props){ //this function will generate a 3D model of a bin based on its settings using multiple meshes, cylinder for the body, cone for the roof // and either a cone for the hopper or circle for flat bottom, it is possible to also use lathe geometry for this as well, // it might even work better because we can control the smoothness easier with it being only one mesh rather than 3 - const {bin, scale = 100, fillPercent} = props + const {bin, scale = 100, fillPercent, nodeClick} = props const binCenter = useMemo(() => new Vector3(0, 0, 0), []); const cableData = useMemo(() => BuildCableData(bin), [bin]); const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]); @@ -80,7 +81,14 @@ export default function Bin3dView(props: Props){ {/* grain - cylinder*/} {grainInventory()} {/* cables */} - + { + // console.log(node) + // console.log(cable) + if(nodeClick){ + nodeClick(node, cable) + } + }}/> diff --git a/src/bin/3dView/Systems/Cables/BinCables.tsx b/src/bin/3dView/Systems/Cables/BinCables.tsx index 452a471..1c526f3 100644 --- a/src/bin/3dView/Systems/Cables/BinCables.tsx +++ b/src/bin/3dView/Systems/Cables/BinCables.tsx @@ -10,16 +10,29 @@ interface Props { nodeData: NodeData[] bin: Bin binCenter: Vector3 + onNodeClick?: (node: NodeData, cable: CableData) => void } export default function BinCables(props: Props){ - const {bin, binCenter, cableData, nodeData} = props + const {bin, binCenter, cableData, nodeData, onNodeClick} = props return ( {cableData.map((cable, i) => { const cableNodes = nodeData.filter(n => n.cableIndex === cable.cableIndex); + return ( - + { + if(onNodeClick){ + onNodeClick(node, cable) + } + }} + /> )} )} diff --git a/src/bin/3dView/Systems/Cables/CableNode.tsx b/src/bin/3dView/Systems/Cables/CableNode.tsx index a5c410f..daf7744 100644 --- a/src/bin/3dView/Systems/Cables/CableNode.tsx +++ b/src/bin/3dView/Systems/Cables/CableNode.tsx @@ -8,7 +8,6 @@ import { quack } from "protobuf-ts/quack" import { useGlobalState } from "providers" import { pond } from "protobuf-ts/pond" import Ring, { RingGeometry } from "3dModels/Shapes/3D/Ring" -// import { Color } from "three"; import { NodeData } from "../../Data/BuildNodeData" import { TempToColour } from "bin/3dView/utils/tempToColour" @@ -17,31 +16,24 @@ interface Props { binCenter: Vector3 lowerThreshold: number upperThreshold: number + onNodeClick?: (node: NodeData) => void } -export default function CableNode(props: Props){ - const {node, binCenter, lowerThreshold, upperThreshold} = props +export default function CableNode(props: Props) { + const { node, binCenter, lowerThreshold, upperThreshold, onNodeClick } = props const { camera } = useThree(); - const [{user}] = useGlobalState(); + const [{ user }] = useGlobalState(); const [showLabel, setShowLabel] = useState(false); const labelGroupRef = React.useRef(null); const ROW_HEIGHT = 35; const PADDING = 20; - //node colour varables for adjusting lighness and saturation for the thresholds - // const colourFade = 20 //this number is the temp diff above or below the thresholds that the node will reach its maximum red or blue intensity - // const minimumLightness = 0.3 //the minimum brightness of the colour, to low would approach black - // const lightnessRange = 0.2 //how much can the node brighten, to high will allow the node to turn white - // const minimumSaturation = 0.7 - // const saturationRange = 0.8 - useFrame(() => { - //use the cameras distance to the center of the bin const distance = camera.position.distanceTo(binCenter) - + const SHOW_DISTANCE = 15; - const HIDE_DISTANCE = 16; // hysteresis buffer - + const HIDE_DISTANCE = 16; + if (!showLabel && distance < SHOW_DISTANCE) { setShowLabel(true); } else if (showLabel && distance > HIDE_DISTANCE) { @@ -55,116 +47,46 @@ export default function CableNode(props: Props){ } }); - const tempDisplay = () => { - let temp = node.celcius.toFixed(2) + "°C" + const tempDisplay = useMemo(() => { if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { - temp = ((node.celcius * 9/5) + 32).toFixed(2) + "°F" + return ((node.celcius * 9 / 5) + 32).toFixed(2) + "°F" } - return temp - } - - // const nodeColour = () => { - // if (!node.inGrain) return "white"; - // if (node.excluded) return "grey"; - - // const lower = lowerThreshold; - // const upper = upperThreshold; - // const temp = node.celcius; - - // const GREEN = new Color("#52c41a"); - // const BLUE = new Color("#3399ff"); - // const RED = new Color("#ff4d4f"); - - // if (temp >= lower && temp <= upper) { - // return GREEN.getStyle(); - // } - - // // shared helper logic concept: - // const getCloseness = (distance: number) => { - // const normalized = Math.min(1, distance / colourFade); - // return 1 - normalized; // 1 = near threshold, 0 = far - // }; - - // const color = new Color(); - - // // BELOW lower (blue) - // if (temp < lower) { - // const distance = lower - temp; - // const closeness = getCloseness(distance); - - // const hsl = { h: 0, s: 1, l: 1 }; - // BLUE.getHSL(hsl); - - // const lightness = (lightnessRange * closeness) + minimumLightness; - - // // 🔥 NEW: saturation as second intensity layer - // const saturation = (saturationRange * (1 - closeness)) + minimumSaturation; - - // color.setHSL(hsl.h, saturation, lightness); - - // return color.getStyle(); - // } - - // // ABOVE upper (red) - // if (temp > upper) { - // const distance = temp - upper; - // const closeness = getCloseness(distance); - - // const hsl = { h: 0, s: 1, l: 1 }; - // RED.getHSL(hsl); - - // const lightness = (lightnessRange * closeness) + minimumLightness; - - // // 🔥 NEW: saturation as second intensity layer - // const saturation = (saturationRange * (1 - closeness)) + minimumSaturation; - - // color.setHSL(hsl.h, saturation, lightness); - - // return color.getStyle(); - // } - - // return GREEN.getStyle(); - // }; + return node.celcius.toFixed(2) + "°C" + }, [node.celcius, user]); const rows = useMemo(() => { const r: { text: string; color: string }[] = []; - + if (node.excluded) { - r.push({ - text: "Excluded", - color: "red" - }); - return r; // nothing else matters + r.push({ text: "Excluded", color: "red" }); + return r; } - + if (node.topNode) { - r.push({ - text: "Top Node", - color: "yellow" - }); + r.push({ text: "Top Node", color: "yellow" }); } - + r.push({ - text: tempDisplay(), + text: tempDisplay, color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour() }); - + if (node.humidity) { r.push({ text: `${node.humidity.toFixed(2)}%`, color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour() }); } - + if (node.moisture) { r.push({ text: `${node.moisture.toFixed(2)}% EMC`, color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour() }); } - + return r; - }, [node, user]); + }, [node, tempDisplay]); const geometry = React.useMemo(() => ({ radius: node.radius, @@ -173,20 +95,20 @@ export default function CableNode(props: Props){ const topNodeGeo = React.useMemo(() => ({ radius: node.radius, thickness: 5, - } as RingGeometry), [node]) + } as RingGeometry), [node]); - const topNodePosition = React.useMemo(() => (new Vector3(node.position.x, node.position.y + 25, node.position.z)), [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 labelPosition = node.position.clone(); const labelHeight = rows.length * ROW_HEIGHT + PADDING; const dir = camera.position.clone().sub(node.position).normalize(); - - // push label slightly toward camera - labelPosition.add(dir.multiplyScalar(1)); // small offset like 0.5–2 units + labelPosition.add(dir.multiplyScalar(1)); const getRowY = (index: number) => { const totalHeight = rows.length * ROW_HEIGHT; @@ -194,59 +116,59 @@ export default function CableNode(props: Props){ }; const nodeColour = () => { - if (node.excluded){ - return "grey" - } - if (!node.inGrain){ - return "white" - } - let c = TempToColour(node, lowerThreshold, upperThreshold, "node") - if (c !== null){ - return c.getStyle() - } - return "white" - } + if (node.excluded) return "grey"; + if (!node.inGrain) return "white"; + const c = TempToColour(node, lowerThreshold, upperThreshold, "node"); + return c !== null ? c.getStyle() : "white"; + }; + const handleClick = (e: any) => { + e.stopPropagation(); + onNodeClick?.(node); + }; return ( {(!node.inGrain || !showLabel) && - - - {node.topNode && ( - - )} - + + + {node.topNode && ( + + )} + } {node.inGrain && showLabel && - - - {/* Background */} - - - - - {/* Rows */} - {rows.map((row, i) => ( - - {row.text} - - ))} - - + + + + + + + {rows.map((row, i) => ( + + {row.text} + + ))} + + } ) diff --git a/src/bin/3dView/Systems/Cables/GrainCable.tsx b/src/bin/3dView/Systems/Cables/GrainCable.tsx index 44c83ff..f8ab324 100644 --- a/src/bin/3dView/Systems/Cables/GrainCable.tsx +++ b/src/bin/3dView/Systems/Cables/GrainCable.tsx @@ -11,9 +11,10 @@ interface Props { nodes: NodeData[] bin: Bin binCenter: Vector3 + onNodeClick?: (node: NodeData) => void } export default function GrainCable(props: Props) { - const {cable, nodes, bin, binCenter} = props + const {cable, nodes, bin, binCenter, onNodeClick} = props const calculateHeight = () => { return cable.topPosition.distanceTo(cable.bottomPosition) @@ -40,7 +41,7 @@ export default function GrainCable(props: Props) { {nodes.map((node, i) => ( - + ))} ) From 6f6a062106cb16e3d3e574e1854626b964152203 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Fri, 24 Apr 2026 11:43:56 -0600 Subject: [PATCH 052/146] trying to fix overlaping temp clouds --- src/bin/3dView/Data/BuildNodeData.ts | 6 + .../3dView/Systems/Heatmap/NodePointCloud.tsx | 235 ++++++++++-------- 2 files changed, 143 insertions(+), 98 deletions(-) diff --git a/src/bin/3dView/Data/BuildNodeData.ts b/src/bin/3dView/Data/BuildNodeData.ts index 4196820..ac8717c 100644 --- a/src/bin/3dView/Data/BuildNodeData.ts +++ b/src/bin/3dView/Data/BuildNodeData.ts @@ -44,6 +44,12 @@ export function BuildNodeData(cables: CableData[]): NodeData[] { const humidity = grainCable.relativeHumidity[i] || undefined; const moisture = grainCable.moisture[i] || undefined; let t = celcius + if(i===0){ + t = 30 + } + if (i===1){ + t = 0 + } nodeData.push({ cableIndex: cableIndex, diff --git a/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx b/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx index 65768de..75d7944 100644 --- a/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx +++ b/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx @@ -15,31 +15,40 @@ export default function NodePointCloud(props: Props) { 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.2; const INTENSITY_BRIGHTNESS_MULT = 0.5; - + const OPACITY = 0.7; const POINT_SIZE = 1; - const getHeatIntensity = ( - temp: number, - lower: number, - upper: number, - fade: number - ) => { - if (temp >= lower && temp <= upper) return 0; + // IDW power — how sharply nearer nodes dominate the field + const IDW_POWER = 2; - const distance = - temp < lower ? lower - temp : temp - upper; + // Minimum net signed magnitude to emit a point. + // Keeps near-zero cancellation zones empty rather than noisy. + const EMIT_THRESHOLD = 0.05; - return Math.min(1, distance / fade); + // ----------------------------- + // 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; }; // ----------------------------- @@ -47,101 +56,135 @@ export default function NodePointCloud(props: Props) { // ----------------------------- 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]); // ----------------------------- - // BUILD POINT CLOUD FROM 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.upperTempThreshold(); + + 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[] = []; - const baseRadius = bin.diameter() * BASE_RADIUS_FACTOR; + 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); - nodes.forEach((node) => { - if (!node.inGrain || node.excluded) return; - - const intensity = getHeatIntensity( - node.celcius, - bin.lowerTempThreshold(), - bin.upperTempThreshold(), - colourFade - ); - - // skip normal nodes - if (intensity === 0) return; - - const isHot = node.celcius > bin.upperTempThreshold(); - - // expand cloud based on severity - const radius = baseRadius * (0.5 + intensity); - - //these control the number of points along that axis - const densityMultiplier = INTENSITY_DENSITY_BASE + intensity * INTENSITY_DENSITY_MULT; - const radialSteps = Math.floor(RADIAL_BASE * densityMultiplier);//points along the radius to the edge - const thetaSteps = Math.floor(THETA_BASE * densityMultiplier);//points along the azimuth (horizontal angle) - const phiSteps = Math.floor(PHI_BASE * densityMultiplier);//points along the vertical angle - - const radiusLimit = bin.diameter() / 2; - const minY = -bin.sidewallHeight() / 2; - for (let rStep = 0; rStep < radialSteps; rStep++) { - // sqrt for even density - const r = Math.sqrt(rStep / radialSteps) * radius; - + const r = Math.sqrt(rStep / radialSteps) * node.cloudRadius; + for (let pStep = 0; pStep < phiSteps; pStep++) { - // avoid poles clustering by not hitting exact 0/PI - const phi = - ((pStep + 0.5) / phiSteps) * Math.PI; - + 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.position.x + - r * Math.sin(phi) * Math.cos(theta); - - const y = - node.position.y + - r * Math.cos(phi); - - const z = - node.position.z + - r * Math.sin(phi) * Math.sin(theta); - - // cylindrical bin bounds + 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 > radiusLimit) continue; - if (y > maxGrainY || y < minY) continue; - + 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); - const falloff = 1 - r / radius; + // 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); - // shape of the curve - const spatialFalloff = Math.pow(falloff, 2); - - const adjustedFalloff = - MIN_EDGE_INTENSITY + (1 - MIN_EDGE_INTENSITY) * spatialFalloff; - - const intensityCurve = intensity * intensity; // quadratic easing - const baseBrightness = - BASE_BRIGHTNESS + INTENSITY_BRIGHTNESS_MULT * intensityCurve; - - const brightness = adjustedFalloff * baseBrightness; - - if (isHot) { - colors.push(brightness, 0, 0); + if (netField > 0) { + colors.push(brightness, 0, 0); // hot → red } else { - colors.push(0, 0, brightness); + colors.push(0, 0, brightness); // cold → blue } } } @@ -152,30 +195,26 @@ export default function NodePointCloud(props: Props) { positions: new Float32Array(positions), colors: new Float32Array(colors), }; - }, [nodes, bin, maxGrainY]); + }, [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 + 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; From aa6219f4b7ad4d640d6ef5f991f80569fec5976d Mon Sep 17 00:00:00 2001 From: csawatzky Date: Mon, 27 Apr 2026 09:12:14 -0600 Subject: [PATCH 053/146] hotfix for when adding alerts from the bin page the conditions in the interaction were being showed in the converted value --- package-lock.json | 2 +- src/device/autoDetect/DeviceScannedComponents.tsx | 1 + src/objects/objectInteractions/NewObjectInteraction.tsx | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2cd53e8..a8d3068 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11205,7 +11205,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#9c0f668d4a56b8216dd71a44c3110a818aaf3541", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#29d9765ce93c04ad70d795b80e9873fc2fc5f600", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/src/device/autoDetect/DeviceScannedComponents.tsx b/src/device/autoDetect/DeviceScannedComponents.tsx index 2be89a9..dda2bf4 100644 --- a/src/device/autoDetect/DeviceScannedComponents.tsx +++ b/src/device/autoDetect/DeviceScannedComponents.tsx @@ -71,6 +71,7 @@ export default function DeviceScannedComponents(props: Props){ let valid: quack.AddressData[] = [] //filter the address data let addr = scannedI2C?.settings?.foundAddresses + console.log(addr) if(addr){ addr.forEach(addrData => { if(!i2cBlacklistAddresses.includes(addrData.address)){ diff --git a/src/objects/objectInteractions/NewObjectInteraction.tsx b/src/objects/objectInteractions/NewObjectInteraction.tsx index 40aed71..8e68a3f 100644 --- a/src/objects/objectInteractions/NewObjectInteraction.tsx +++ b/src/objects/objectInteractions/NewObjectInteraction.tsx @@ -262,7 +262,7 @@ export default function NewObjectInteraction(props: Props){ const conditionGroup = (condition: pond.InteractionCondition, index: number) => { let measurementType = condition.measurementType; - let describer = describeMeasurement(measurementType, newAlertComponentType); + let describer = describeMeasurement(measurementType, newAlertComponentType, undefined, undefined, user); let isBoolean = condition.measurementType === Measurement.boolean; return ( From 23de0a3bca4e7e88411bdea303b6a1a52e88df49 Mon Sep 17 00:00:00 2001 From: Carter Date: Mon, 27 Apr 2026 14:09:50 -0600 Subject: [PATCH 054/146] created local deployment method that excludes crisp --- deploy.sh | 70 ++++++++++++++++++++++++++++++++++++----- indexLocal.html | 15 +++++++++ package.json | 4 +-- src/app/UserWrapper.tsx | 24 ++++++-------- src/chat/ChatDrawer.tsx | 20 ++++++------ src/chat/CrispChat.ts | 8 +++++ vite.config.ts | 57 ++++++++++++++++++++++++++++++--- 7 files changed, 160 insertions(+), 38 deletions(-) mode change 100644 => 100755 deploy.sh create mode 100644 indexLocal.html diff --git a/deploy.sh b/deploy.sh old mode 100644 new mode 100755 index cdcd400..1be7fa9 --- a/deploy.sh +++ b/deploy.sh @@ -1,11 +1,65 @@ -# 1. Build -docker build -t webui:local -f pond/Dockerfile . +#!/usr/bin/env bash +set -euo pipefail -# 2. Transfer image -docker save webui:local | ssh -i ~/.ssh/id_ed25519 carter@172.16.1.20 docker load +# Load .env.local if it exists +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="$SCRIPT_DIR/.env.local" -# 3. SCP compose file -scp -i ~/.ssh/id_ed25519 docker-compose.local.yml carter@172.16.1.20:~/ +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 -# 4. Deploy -ssh -i ~/.ssh/id_ed25519 carter@172.16.1.20 "docker stack deploy -c docker-compose.local.yml webui" \ No newline at end of file +# 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 (uses indexLocal.html — no Crisp; see vite --mode nocrisp) +( + cd "$SCRIPT_DIR" + npm run build:local +) + +# 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 +if grep -qiE 'crisp\.chat|\$crisp|CRISP_WEBSITE_ID|CRISP_RUNTIME_CONFIG' "$INDEX_HTML"; then + echo "error: build/index.html still references Crisp; expected nocrisp build (indexLocal.html)" >&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" \ No newline at end of file diff --git a/indexLocal.html b/indexLocal.html new file mode 100644 index 0000000..d31ab53 --- /dev/null +++ b/indexLocal.html @@ -0,0 +1,15 @@ + + + + + + + + Adaptive Dashboard + + + +
+ + + diff --git a/package.json b/package.json index 31aea89..6dba33e 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "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 nocrisp", "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", @@ -16,7 +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_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", + "build:local": "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 nocrisp", "build:offline": "npx env-cmd offline,whitelabel npm run build", "test": "vitest" }, diff --git a/src/app/UserWrapper.tsx b/src/app/UserWrapper.tsx index fac8487..f837b70 100644 --- a/src/app/UserWrapper.tsx +++ b/src/app/UserWrapper.tsx @@ -12,9 +12,8 @@ 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 FirmwareLoader from './FirmwareLoader' const reducer = (state: GlobalState, action: GlobalStateAction): GlobalState => { @@ -72,9 +71,6 @@ export default function UserWrapper(props: Props) { const user_id = or(useAuth.user?.sub, "") - const crispInitialized = useRef(false); - Crisp.configure(import.meta.env.VITE_CRISP_WEBSITE_ID); - const loadUser = useCallback(() => { if (!userAPI.getUserWithTeam) return; if (hasFetched.current) return; @@ -114,15 +110,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 +145,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"); diff --git a/src/chat/ChatDrawer.tsx b/src/chat/ChatDrawer.tsx index fa4ae4e..01eebcd 100644 --- a/src/chat/ChatDrawer.tsx +++ b/src/chat/ChatDrawer.tsx @@ -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) => ({ @@ -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) {
- - - - - + {isCrispEnabled() && ( + + + + + + )} {teams.map((t, i)=> { if (t.settings?.key === team.key()) return null; diff --git a/src/chat/CrispChat.ts b/src/chat/CrispChat.ts index a0220fb..fe9fc16 100644 --- a/src/chat/CrispChat.ts +++ b/src/chat/CrispChat.ts @@ -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(); } diff --git a/vite.config.ts b/vite.config.ts index f48db4a..bc0fc1b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,12 +1,56 @@ -import { defineConfig } from 'vite' +import { defineConfig, type Plugin, type UserConfig } from 'vite' import react from '@vitejs/plugin-react' import tsconfigPaths from 'vite-tsconfig-paths' import { VitePWA } from 'vite-plugin-pwa'; import * as path from 'path' // ✅ Import path module +import { readFileSync, renameSync, existsSync, unlinkSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +const rootDir = path.dirname(fileURLToPath(import.meta.url)) + +const NO_CRISP_MODE = 'nocrisp' + +function useNoCrispIndexHtml (mode: string, command: string): Plugin { + if (mode !== NO_CRISP_MODE || command !== 'serve') { + return { name: 'use-local-index-html-noop' } + } + return { + name: 'use-local-index-html', + apply: 'serve', + transformIndexHtml: { + order: 'pre', + handler () { + return readFileSync(path.join(rootDir, 'indexLocal.html'), 'utf-8') + }, + }, + } +} + +function emitNoCrispIndexAsIndexHtml (mode: string): Plugin { + if (mode !== NO_CRISP_MODE) { + return { name: 'emit-nocrisp-index-as-index-noop' } + } + return { + name: 'emit-nocrisp-index-as-index-html', + closeBundle () { + const outDir = path.join(rootDir, 'build') + const from = path.join(outDir, 'indexLocal.html') + const to = path.join(outDir, 'index.html') + if (existsSync(from)) { + if (existsSync(to)) unlinkSync(to) + renameSync(from, to) + } + }, + } +} // https://vitejs.dev/config/ -export default defineConfig({ +export default defineConfig(({ command, mode }): UserConfig => { + const useNoCrispIndex = mode === NO_CRISP_MODE + return { plugins: [ + useNoCrispIndexHtml(mode, command), + emitNoCrispIndexAsIndexHtml(mode), react(), tsconfigPaths(), VitePWA({ @@ -53,12 +97,15 @@ export default defineConfig({ target: 'esnext', rollupOptions: { input: { - main: path.resolve(__dirname, 'index.html') - } + main: path.join( + rootDir, + useNoCrispIndex ? 'indexLocal.html' : 'index.html' + ), + }, } }, esbuild: { keepNames: true, // Prevent function name mangling }, - + } }) From 8169e280ec75be87aa8c67b988e1d4128aec1712 Mon Sep 17 00:00:00 2001 From: Carter Date: Mon, 27 Apr 2026 14:42:56 -0600 Subject: [PATCH 055/146] front end flow for bypassing auth0 on local buils --- deploy.sh | 4 ---- src/app/App.tsx | 12 ++++++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/deploy.sh b/deploy.sh index 1be7fa9..daa6b63 100755 --- a/deploy.sh +++ b/deploy.sh @@ -43,10 +43,6 @@ if [[ ! -f "$INDEX_HTML" ]]; then echo "error: missing $INDEX_HTML after build:local" >&2 exit 1 fi -if grep -qiE 'crisp\.chat|\$crisp|CRISP_WEBSITE_ID|CRISP_RUNTIME_CONFIG' "$INDEX_HTML"; then - echo "error: build/index.html still references Crisp; expected nocrisp build (indexLocal.html)" >&2 - exit 1 -fi # 2. Build container image docker build -t webui:local -f Dockerfile . diff --git a/src/app/App.tsx b/src/app/App.tsx index 2c6b94b..09956c2 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,9 +1,11 @@ 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' @@ -54,6 +56,16 @@ function App() { "/libracart" ] + if (!shouldMountAuth0Provider()) { + const placeholderReason = + isAuth0Configured() && !isAuth0SpaOriginAllowed() ? 'insecure_origin' : 'unconfigured' + return ( + + + + ) + } + return ( Date: Tue, 28 Apr 2026 17:00:09 -0600 Subject: [PATCH 056/146] various tweaks and now trying to build heatmaps in two different ways --- src/3dModels/Shapes/BaseMesh.tsx | 12 +- src/bin/3dView/BinParts/BinShell.tsx | 19 +- src/bin/3dView/Data/BuildNodeData.ts | 1 + src/bin/3dView/Scene/Bin3dView.tsx | 21 +- src/bin/3dView/Systems/Cables/BinCables.tsx | 6 +- src/bin/3dView/Systems/Cables/CableNode.tsx | 9 +- src/bin/3dView/Systems/Cables/GrainCable.tsx | 9 +- .../3dView/Systems/Heatmap/HeatMapAlpha.tsx | 634 ++++++++++++------ .../3dView/Systems/Heatmap/NodePointCloud.tsx | 12 +- .../3dView/Systems/Heatmap/TempHeatMap.tsx | 406 +++++++++++ .../Systems/Inventory/GrainCableFill.tsx | 35 +- .../Systems/Inventory/GrainFillFlat.tsx | 12 +- 12 files changed, 932 insertions(+), 244 deletions(-) create mode 100644 src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx diff --git a/src/3dModels/Shapes/BaseMesh.tsx b/src/3dModels/Shapes/BaseMesh.tsx index d92601d..e06d622 100644 --- a/src/3dModels/Shapes/BaseMesh.tsx +++ b/src/3dModels/Shapes/BaseMesh.tsx @@ -16,6 +16,8 @@ export interface BaseMeshProps { materialOverride?: React.ReactNode; side?: Side; depthWrite?: boolean; + depthTest?: boolean; + renderOrder?: number; onClick?: (event: ThreeEvent) => void; } @@ -35,6 +37,8 @@ export default function BaseMesh(props: BaseMeshProps) { materialOverride, side, depthWrite = true, + depthTest = true, + renderOrder = 0, onClick, } = props; @@ -50,6 +54,7 @@ export default function BaseMesh(props: BaseMeshProps) { transparent={isTransparent} opacity={opacity} depthWrite={depthWrite} + depthTest={depthTest} side={side} {...materialProps} /> @@ -64,7 +69,8 @@ export default function BaseMesh(props: BaseMeshProps) { roughness={roughness} transparent={isTransparent} opacity={opacity} - depthWrite={!isTransparent} + depthWrite={depthWrite} + depthTest={depthTest} side={side} {...materialProps} /> @@ -73,7 +79,7 @@ export default function BaseMesh(props: BaseMeshProps) { }; return ( - + {/* Main surface */} {buildMaterial()} @@ -81,7 +87,7 @@ export default function BaseMesh(props: BaseMeshProps) { {/* Optional wireframe overlay */} {wireframe && ( - + ({ radiusTop: diameter / 2, radiusBottom: diameter / 2, @@ -86,7 +87,10 @@ export default function BinShell(props: Props) { position={roofPosition} roughness={binRoughness} side={2} - opacity={binOpacity}/> + opacity={binOpacity} + depthWrite={false} + depthTest={false} + renderOrder={renderOrder}/> {/* bin sidewall - cylinder (open ended for the roof and bottom)*/} + opacity={binOpacity} + depthWrite={false} + depthTest={false} + renderOrder={renderOrder}/> {/* bin bottom - cone -OR- circle depending on the bins shape*/} {hopperHeight !== undefined && hopperHeight > 0 ? : }
diff --git a/src/bin/3dView/Data/BuildNodeData.ts b/src/bin/3dView/Data/BuildNodeData.ts index ac8717c..9d7de98 100644 --- a/src/bin/3dView/Data/BuildNodeData.ts +++ b/src/bin/3dView/Data/BuildNodeData.ts @@ -44,6 +44,7 @@ export function BuildNodeData(cables: CableData[]): NodeData[] { const humidity = grainCable.relativeHumidity[i] || undefined; const moisture = grainCable.moisture[i] || undefined; let t = celcius + // for testing if(i===0){ t = 30 } diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index 403dc8f..a704007 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -8,9 +8,11 @@ import { Vector3 } from "three"; import { useMemo } from "react"; import { BuildCableData, CableData } from "../Data/BuildCableData"; import { BuildNodeData, NodeData } from "../Data/BuildNodeData"; -import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; +import Heatmap from "../Systems/Heatmap/HeatMapAlpha"; import { pond } from "protobuf-ts/pond"; import GrainCableFill from "../Systems/Inventory/GrainCableFill"; +import TempHeatMap from "../Systems/Heatmap/TempHeatMap"; +// import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; interface Props { /** @@ -28,13 +30,17 @@ interface Props { */ fillPercent?: number nodeClick?: (node: NodeData, cable: CableData) => void + /** + * When true, renders the heatmap instead of the grain fill. + */ + showHeatmap?: boolean } export default function Bin3dView(props: Props){ //this function will generate a 3D model of a bin based on its settings using multiple meshes, cylinder for the body, cone for the roof // and either a cone for the hopper or circle for flat bottom, it is possible to also use lathe geometry for this as well, // it might even work better because we can control the smoothness easier with it being only one mesh rather than 3 - const {bin, scale = 100, fillPercent, nodeClick} = props + const {bin, scale = 100, fillPercent, nodeClick, showHeatmap = false} = props const binCenter = useMemo(() => new Vector3(0, 0, 0), []); const cableData = useMemo(() => BuildCableData(bin), [bin]); const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]); @@ -58,6 +64,7 @@ export default function Bin3dView(props: Props){ sidewallHeight={bin.sidewallHeight()} hopperHeight={bin.hopperHeight()} fillPercent={fillPercent} + grainOpacity={0.3} /> } } @@ -75,11 +82,11 @@ export default function Bin3dView(props: Props){ sidewallHeight={bin.sidewallHeight()} roofHeight={bin.roofHeight()} hopperHeight={bin.hopperHeight()} - + renderOrder={4} /> {/* grain - cylinder*/} - {grainInventory()} + {/* {!showHeatmap && grainInventory()} */} {/* cables */} { @@ -88,8 +95,10 @@ export default function Bin3dView(props: Props){ if(nodeClick){ nodeClick(node, cable) } - }}/> - + }} + renderOrder={1}/> + {/* */} + {/* lighting */} diff --git a/src/bin/3dView/Systems/Cables/BinCables.tsx b/src/bin/3dView/Systems/Cables/BinCables.tsx index 1c526f3..6adedd1 100644 --- a/src/bin/3dView/Systems/Cables/BinCables.tsx +++ b/src/bin/3dView/Systems/Cables/BinCables.tsx @@ -9,12 +9,13 @@ interface Props { cableData: CableData[] nodeData: NodeData[] bin: Bin - binCenter: Vector3 + binCenter: Vector3, + renderOrder?: number, onNodeClick?: (node: NodeData, cable: CableData) => void } export default function BinCables(props: Props){ - const {bin, binCenter, cableData, nodeData, onNodeClick} = props + const {bin, binCenter, cableData, nodeData, onNodeClick, renderOrder} = props return ( {cableData.map((cable, i) => { @@ -32,6 +33,7 @@ export default function BinCables(props: Props){ onNodeClick(node, cable) } }} + renderOrder={renderOrder} /> )} )} diff --git a/src/bin/3dView/Systems/Cables/CableNode.tsx b/src/bin/3dView/Systems/Cables/CableNode.tsx index daf7744..782b40f 100644 --- a/src/bin/3dView/Systems/Cables/CableNode.tsx +++ b/src/bin/3dView/Systems/Cables/CableNode.tsx @@ -15,12 +15,13 @@ interface Props { node: NodeData binCenter: Vector3 lowerThreshold: number - upperThreshold: number + upperThreshold: number, + renderOrder?: number, onNodeClick?: (node: NodeData) => void } export default function CableNode(props: Props) { - const { node, binCenter, lowerThreshold, upperThreshold, onNodeClick } = props + const { node, binCenter, lowerThreshold, upperThreshold, onNodeClick, renderOrder } = props const { camera } = useThree(); const [{ user }] = useGlobalState(); const [showLabel, setShowLabel] = useState(false); @@ -136,9 +137,11 @@ export default function CableNode(props: Props) { position={node.position} colour={nodeColour()} onClick={handleClick} + renderOrder={renderOrder} + /> {node.topNode && ( - + )} } diff --git a/src/bin/3dView/Systems/Cables/GrainCable.tsx b/src/bin/3dView/Systems/Cables/GrainCable.tsx index f8ab324..938dc63 100644 --- a/src/bin/3dView/Systems/Cables/GrainCable.tsx +++ b/src/bin/3dView/Systems/Cables/GrainCable.tsx @@ -10,11 +10,12 @@ interface Props { cable: CableData nodes: NodeData[] bin: Bin - binCenter: Vector3 + binCenter: Vector3, + renderOrder?: number, onNodeClick?: (node: NodeData) => void } export default function GrainCable(props: Props) { - const {cable, nodes, bin, binCenter, onNodeClick} = props + const {cable, nodes, bin, binCenter, onNodeClick, renderOrder} = props const calculateHeight = () => { return cable.topPosition.distanceTo(cable.bottomPosition) @@ -39,9 +40,9 @@ export default function GrainCable(props: Props) { return ( - + {nodes.map((node, i) => ( - + ))} ) diff --git a/src/bin/3dView/Systems/Heatmap/HeatMapAlpha.tsx b/src/bin/3dView/Systems/Heatmap/HeatMapAlpha.tsx index 65fa1c1..a67e44b 100644 --- a/src/bin/3dView/Systems/Heatmap/HeatMapAlpha.tsx +++ b/src/bin/3dView/Systems/Heatmap/HeatMapAlpha.tsx @@ -1,13 +1,71 @@ -import { NodeData } from "bin/3dView/Data/BuildNodeData"; -import { colourFade, TempToColour } from "bin/3dView/utils/tempToColour"; import { Bin } from "models"; -import { pond } from "protobuf-ts/pond"; import { useMemo } from "react"; -import { AdditiveBlending, Vector3 } from "three"; +import { + Color, + ShaderMaterial, +} from "three"; +import { useThree } from "@react-three/fiber"; + +import { NodeData } from "bin/3dView/Data/BuildNodeData"; +import { colourFade } from "bin/3dView/utils/tempToColour"; interface Props{ bin: Bin nodes: NodeData[] + opacity?: number + /** + * Point opacity (lower = see deeper). + */ + pointOpacity?: number + /** + * Point size in world units (scaled with your Bin3dView scale group). + */ + pointSize?: number + /** + * Enables MSAA alpha coverage smoothing (WebGL2 + MSAA). + * Helps look continuous without additive blending. + */ + alphaToCoverage?: boolean + /** + * Density along Y (vertical slices). + */ + ySlices?: number + /** + * Radial rings per slice. + */ + radialRings?: number + /** + * Angular segments per ring. + */ + thetaSegments?: number + /** + * Inset to avoid z-fighting with the shell. + */ + wallInsetFactor?: number + /** + * Adds jittered samples inside each polar cell to better fill the volume. + */ + samplesPerCell?: number + /** + * 0..1 jitter amount within a cell (0 = none). + */ + jitter?: number + /** + * Enables screen-door alpha hashing. This fixes incorrect transparency sorting + * (points popping in front when tilted) without additive blending. + */ + alphaHash?: boolean + /** + * Makes in-threshold (green) points more transparent so hot/cold pockets show through. + * 0..1 where 0 = invisible green, 1 = same opacity as out-of-threshold. + */ + greenOpacityFactor?: number + /** + * Curves how strongly out-of-threshold points become visible. + * >1 makes only strong deviations pop; <1 makes small deviations pop more. + */ + deviationPower?: number + // (reverted) extra perf knobs removed } /** @@ -16,223 +74,379 @@ interface Props{ * @returns */ export default function Heatmap(props: Props){ - const {bin, nodes} = props - //the steps that control how many 'layers' the heatmap will generate for each direction - const radialSteps = 18; // number of points across the diameter of the pin - const heightSteps = 20; // number of points up the side of the bin - const angleSteps = 40; // number of points around the circumfrence of the bin + const { + bin, + nodes, + opacity = 0.65, // kept for backward compatibility + pointOpacity, + pointSize = 5, + ySlices = 22, + radialRings = 16, + thetaSegments = 28, + wallInsetFactor = 0.99, + samplesPerCell = 1, + jitter = 0.75, + alphaHash = true, + greenOpacityFactor = 0.18, + deviationPower = 0.6, + } = props; - const getHeatIntensity = ( - temp: number, - lower: number, - upper: number, - fade: number - ) => { - if (temp >= lower && temp <= upper) return 0; - - const distance = - temp < lower ? lower - temp : temp - upper; - - // clamp 0 → 1 - return Math.min(1, distance / fade); - } + useThree(); // keep fiber context available if needed later - const getTemperatureAtPoint = ( - point: Vector3, - nodes: NodeData[] - ) => { - let totalWeight = 0; - let weightedTemp = 0; - - //not sure if we should use a hard coded value or use a percentage of the bins diameter - //const maxDistance = 450; //tweak this (cm), it is the max distance that a node can influence the heatmap points - const maxDistance = bin.diameter() * 0.25; + const sidewallHeight = bin.sidewallHeight(); + const hopperHeight = bin.hopperHeight() ?? 0; + const sidewallBaseY = -sidewallHeight / 2; + const hopperTipY = sidewallBaseY - hopperHeight; - nodes.forEach(node => { - if (!node.inGrain || node.excluded) return; - - const distance = point.distanceTo(node.position); + const inGrainNodes = useMemo( + () => nodes.filter((n) => n.inGrain && !n.excluded), + [nodes], + ); - const intensity = getHeatIntensity( - node.celcius, - bin.lowerTempThreshold(), - bin.upperTempThreshold(), - colourFade - ); - - const nodeMaxDistance = maxDistance * (0.5 + intensity); - // tweakable: 0.5–1.5 range - - if (distance > nodeMaxDistance) return; - - const weight = 1 / (distance * distance + 1); - - totalWeight += weight; - weightedTemp += node.celcius * weight; - }); - - if (totalWeight === 0) return null; - - return weightedTemp / totalWeight; + const maxGrainY = useMemo(() => { + let maxY = -Infinity; + for (const n of inGrainNodes) maxY = Math.max(maxY, n.position.y); + return Number.isFinite(maxY) ? maxY : sidewallBaseY; + }, [inGrainNodes, sidewallBaseY]); + + const topNodes = useMemo( + () => nodes.filter((n) => n.topNode && n.inGrain && !n.excluded), + [nodes], + ); + + const anchors = useMemo( + () => + topNodes.map((n) => ({ + x: n.position.x, + z: n.position.z, + y: n.position.y + n.nodeSpacing * 0.5, + })), + [topNodes], + ); + + const wallY = useMemo(() => { + if (anchors.length === 0) return -sidewallHeight / 2; + return anchors.reduce((sum, a) => sum + a.y, 0) / anchors.length; + }, [anchors, sidewallHeight]); + + const idwHeight = ( + x: number, + z: number, + inputAnchors: { x: number; z: number; y: number }[], + power = 2, + ): number => { + let totalWeight = 0; + let weightedY = 0; + + for (const a of inputAnchors) { + const dx = x - a.x; + const dz = z - a.z; + const distSq = dx * dx + dz * dz; + if (distSq < 0.001) return a.y; + const w = 1 / Math.pow(distSq, power / 2); + totalWeight += w; + weightedY += a.y * w; } - //this gets the highest top node to prevent points from being rendered above it - //we could in the future us the multple top nodes to clamp within a cloumn around that cable which would give us a more realistic grain area - 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]); + return weightedY / totalWeight; + }; - const samplePoints = useMemo(() => { - const points = []; - - const radius = bin.diameter() / 2; - const height = bin.sidewallHeight(); - + const maxRadiusAtY = (y: number, maxR: number): number => { + if (y >= sidewallBaseY) return maxR; + if (hopperHeight <= 0 || y <= hopperTipY) return 0; + const t = (y - hopperTipY) / hopperHeight; // 0..1 + return maxR * t; + }; - - for (let yStep = 0; yStep < heightSteps; yStep++) { - const y = -height / 2 + (yStep / heightSteps) * height; - if (y > maxGrainY) continue; - - for (let rStep = 0; rStep < radialSteps; rStep++) { - // const r = (rStep / radialSteps) * radius; - const r = Math.sqrt(rStep / radialSteps) * radius; - - for (let aStep = 0; aStep < angleSteps; aStep++) { - const angle = (aStep / angleSteps) * Math.PI * 2; - - const x = Math.cos(angle) * r; - const z = Math.sin(angle) * r; - - const position = new Vector3(x, y, z); - - const temp = getTemperatureAtPoint(position, nodes); - - points.push({ - position, - temp - }); - } + const grainSurfaceY = (x: number, z: number, rNorm: number): number => { + // If we don't have top nodes, use a flat surface at maxGrainY. + if (anchors.length === 0) return maxGrainY; + + const rawY = idwHeight(x, z, anchors); + + // Match `GrainCableFill` outer-wall taper so switching isn't jarring. + const edgeStart = 0.8; + const blendT = Math.max(0, (rNorm - edgeStart) / (1 - edgeStart)); + const s = blendT * blendT * (3 - 2 * blendT); + const y = rawY * (1 - s) + wallY * s; + return Math.max(-sidewallHeight / 2, Math.min(sidewallHeight / 2, y)); + }; + + const evaluateTemp = (px: number, py: number, pz: number): number | null => { + if (inGrainNodes.length === 0) return null; + + // Inverse-distance weighted interpolation. + // Keep power modest so the field stays smooth. + const IDW_POWER = 2; + let totalWeight = 0; + let weightedSum = 0; + + for (const n of inGrainNodes) { + const dx = px - n.position.x; + const dy = py - n.position.y; + const dz = pz - n.position.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 += n.celcius * weight; + } + + if (totalWeight === 0) return null; + return weightedSum / totalWeight; + }; + + const tempToHeatColor = (temp: number): Color => { + // Match your 2D/point visuals: green in-threshold, fade to red/blue as distance grows. + const lower = bin.lowerTempThreshold(); + const upper = bin.upperTempThreshold(); + + const GREEN = new Color("#52c41a"); + const BLUE = new Color("#3399ff"); + const RED = new Color("#ff4d4f"); + + if (temp >= lower && temp <= upper) return GREEN; + + const distance = temp < lower ? lower - temp : temp - upper; + const intensity = Math.min(1, distance / colourFade); // 0..1 + + // Similar HSL shaping as `TempToColour`, but always returns a color. + const minimumLightness = 0.3; + const lightnessRange = 0.2; + const minimumSaturation = 0.7; + const saturationRange = 0.8; + + const hsl = { h: 0, s: 1, l: 1 }; + (temp < lower ? BLUE : RED).getHSL(hsl); + + const c = new Color(); + c.setHSL( + hsl.h, + saturationRange * intensity + minimumSaturation, + lightnessRange * intensity + minimumLightness, + ); + return c; + }; + + const tempToDeviation = (temp: number): number => { + const lower = bin.lowerTempThreshold(); + const upper = bin.upperTempThreshold(); + if (temp >= lower && temp <= upper) return 0; + const distance = temp < lower ? lower - temp : temp - upper; + return Math.min(1, distance / colourFade); + }; + + const { positions, colors, deviations } = useMemo(() => { + const binR = bin.diameter() / 2; + // Important: points are rendered as *sprites*, so even if the center is inside the wall, + // the visible circle can extend outside. Shrink the sampling radius by ~half pointSize + // so the rendered splats stay within the bin. + const maxR = Math.max(0, binR * wallInsetFactor - pointSize * 0.55); + + const y0 = hopperHeight > 0 ? hopperTipY : sidewallBaseY; + const y1 = Math.max(y0, maxGrainY); + + const pos: number[] = []; + const col: number[] = []; + const dev: number[] = []; + const tmpColor = new Color(); + + const safeYSlices = Math.max(6, Math.floor(ySlices)); + const safeRings = Math.max(4, Math.floor(radialRings)); + const safeTheta = Math.max(12, Math.floor(thetaSegments)); + const safeSamples = Math.max(1, Math.floor(samplesPerCell)); + const j = Math.min(1, Math.max(0, jitter)); + // Deterministic "random" so the cloud doesn't shimmer every render. + const rand01 = (seed: number) => { + // xorshift32 + let x = seed | 0; + x ^= x << 13; + x ^= x >>> 17; + x ^= x << 5; + // convert to [0,1) + return ((x >>> 0) % 1000000) / 1000000; + }; + + for (let yi = 0; yi < safeYSlices; yi++) { + const ty = safeYSlices === 1 ? 0 : yi / (safeYSlices - 1); + const y = y0 + (y1 - y0) * ty; + + const rAtY = maxRadiusAtY(y, maxR); + if (rAtY <= 0.001) continue; + + for (let ring = 0; ring < safeRings; ring++) { + for (let seg = 0; seg < safeTheta; seg++) { + // Cell bounds in polar space + const ring0 = ring / safeRings; + const ring1 = (ring + 1) / safeRings; + const r0 = Math.sqrt(ring0) * rAtY; + const r1 = Math.sqrt(ring1) * rAtY; + + const theta0 = (seg / safeTheta) * Math.PI * 2; + const theta1 = ((seg + 1) / safeTheta) * Math.PI * 2; + + for (let s = 0; s < safeSamples; s++) { + const seed = yi * 73856093 + ring * 19349663 + seg * 83492791 + s * 2654435761; + const u = rand01(seed); + const v = rand01(seed ^ 0x9e3779b9); + + // Jitter inside the cell + const rr = r0 + (r1 - r0) * (j === 0 ? 0.5 : (0.5 + (u - 0.5) * j)); + const tt = theta0 + (theta1 - theta0) * (j === 0 ? 0.5 : (0.5 + (v - 0.5) * j)); + + const x = Math.cos(tt) * rr; + const z = Math.sin(tt) * rr; + + const rNorm = rAtY <= 0 ? 0 : rr / rAtY; + const surfaceY = grainSurfaceY(x, z, rNorm); + if (y > surfaceY) continue; + + const temp = evaluateTemp(x, y, z); + const d0 = temp == null ? 0 : tempToDeviation(temp); + + pos.push(x, y, z); + const c = temp == null ? tmpColor.set("#52c41a") : tempToHeatColor(temp); + col.push(c.r, c.g, c.b); + dev.push(d0); } } - - return points; - }, [bin, nodes]); + } + } - const { positions, colors, alphas } = useMemo(() => { - const positions: number[] = []; - const colors: number[] = []; - const alphas: number[] = []; - - samplePoints.forEach((p) => { - if (p.temp === null) return; - const intensity = getHeatIntensity( - p.temp, - bin.lowerTempThreshold(), - bin.upperTempThreshold(), - colourFade - ); - const alpha = Math.pow(intensity, 2); // try 2 → 3 for stronger fade - - const virtualNode: NodeData = { - cableIndex: -1, - nodeIndex: -1, - radius: 0, - position: p.position, - celcius: p.temp, - inGrain: true, - excluded: false - }; - - const color = TempToColour( - virtualNode, - bin.lowerTempThreshold(), - bin.upperTempThreshold(), - "heatmap" - ); - - if (!color) return; - - // position - positions.push(p.position.x, p.position.y, p.position.z); - - // color (normalized 0–1) - colors.push(color.r, color.g, color.b); - alphas.push(alpha) + return { + positions: new Float32Array(pos), + colors: new Float32Array(col), + deviations: new Float32Array(dev), + }; + }, [ + bin, + wallInsetFactor, + hopperHeight, + hopperTipY, + sidewallBaseY, + maxGrainY, + ySlices, + radialRings, + thetaSegments, + anchors, + wallY, + inGrainNodes, + samplesPerCell, + jitter, + deviationPower, + ]); - }); - - return { - positions: new Float32Array(positions), - colors: new Float32Array(colors), - alphas: new Float32Array(alphas) - }; - }, [samplePoints, bin]); + const alphaHashedMaterial = useMemo(() => { + return new ShaderMaterial({ + transparent: !alphaHash, + depthTest: true, + depthWrite: alphaHash, + uniforms: { + uOpacity: { value: pointOpacity ?? opacity }, + uSize: { value: pointSize }, + uMaxRadius: { value: (bin.diameter() / 2) * wallInsetFactor }, + uSidewallBaseY: { value: -bin.sidewallHeight() / 2 }, + uHopperHeight: { value: bin.hopperHeight() ?? 0 }, + uAlphaHash: { value: alphaHash ? 1 : 0 }, + uGreenOpacityFactor: { value: Math.min(1, Math.max(0, greenOpacityFactor)) }, + uDeviationPower: { value: Math.max(0.05, deviationPower) }, + }, + vertexShader: ` + uniform float uSize; + varying vec3 vWorldPos; + varying vec3 vColor; + varying float vDev; + attribute vec3 color; + attribute float deviation; + void main() { + vColor = color; + vDev = deviation; + vec4 world = modelMatrix * vec4(position, 1.0); + vWorldPos = world.xyz; + vec4 mvPosition = viewMatrix * world; + float attn = 300.0 / max(1.0, -mvPosition.z); + gl_PointSize = uSize * attn; + gl_Position = projectionMatrix * mvPosition; + } + `, + fragmentShader: ` + precision highp float; + uniform float uOpacity; + uniform float uMaxRadius; + uniform float uSidewallBaseY; + uniform float uHopperHeight; + uniform float uAlphaHash; + uniform float uGreenOpacityFactor; + uniform float uDeviationPower; + varying vec3 vColor; + varying float vDev; + varying vec3 vWorldPos; + // interleaved gradient noise + float ign(vec2 p) { + return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); + } + void main() { + // Hard clip pixels to bin radius at this Y (prevents splats outside wall). + float y = vWorldPos.y; + float sidewallBaseY = uSidewallBaseY; + float hopperHeight = uHopperHeight; + float hopperTipY = sidewallBaseY - hopperHeight; + float maxR; + if (y >= sidewallBaseY) { + maxR = uMaxRadius; + } else if (hopperHeight <= 0.0 || y <= hopperTipY) { + maxR = 0.0; + } else { + float t = (y - hopperTipY) / hopperHeight; + maxR = uMaxRadius * t; + } + float r = length(vWorldPos.xz); + if (r > maxR) discard; - return ( - - - - - - - - - - ); + vec2 p = gl_PointCoord - vec2(0.5); + float d = length(p) * 2.0; + float mask = smoothstep(1.0, 0.0, d); + + float dev = clamp(vDev, 0.0, 1.0); + float devCurve = pow(dev, uDeviationPower); + // 0 => green/in-threshold, 1 => strong deviation + float localOpacityFactor = mix(uGreenOpacityFactor, 1.0, devCurve); + float a = clamp(mask * uOpacity * localOpacityFactor, 0.0, 1.0); + + if (uAlphaHash > 0.5) { + float n = ign(gl_FragCoord.xy); + if (n > a) discard; + gl_FragColor = vec4(vColor, 1.0); + } else { + gl_FragColor = vec4(vColor, a); + } + } + `, + }); + }, [alphaHash, bin, deviationPower, greenOpacityFactor, opacity, pointOpacity, pointSize, wallInsetFactor]); + + // Fallback: normal points (no OIT) + return ( + + + + + + + + ); } \ No newline at end of file diff --git a/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx b/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx index 75d7944..1af8ba8 100644 --- a/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx +++ b/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx @@ -2,7 +2,7 @@ import { NodeData } from "bin/3dView/Data/BuildNodeData"; import { colourFade } from "bin/3dView/utils/tempToColour"; import { Bin } from "models"; import { useMemo } from "react"; -import { AdditiveBlending, CanvasTexture } from "three"; +import { CanvasTexture, NormalBlending } from "three"; interface Props { bin: Bin; @@ -22,10 +22,10 @@ export default function NodePointCloud(props: Props) { const MIN_EDGE_INTENSITY = 0.2; - const BASE_BRIGHTNESS = 0.2; - const INTENSITY_BRIGHTNESS_MULT = 0.5; + const BASE_BRIGHTNESS = 0.7; + const INTENSITY_BRIGHTNESS_MULT = 0.7; - const OPACITY = 0.7; + const OPACITY = 0.3; const POINT_SIZE = 1; // IDW power — how sharply nearer nodes dominate the field @@ -224,7 +224,7 @@ export default function NodePointCloud(props: Props) { // RENDER // ----------------------------- return ( - + ); diff --git a/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx b/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx new file mode 100644 index 0000000..cbb9d0d --- /dev/null +++ b/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx @@ -0,0 +1,406 @@ +import { useMemo } from "react"; +import * as THREE from "three"; +import { Bin } from "models"; +import { NodeData } from "../../Data/BuildNodeData"; + +interface Props { + bin: Bin; + nodes: NodeData[]; +} + +// ----------------------------------------------------------------------- +// 🎛️ TUNING KNOBS +// ----------------------------------------------------------------------- + +// Grid resolution — more = smoother but heavier +const RADIAL_RINGS = 12; // rings of sample points from center outward +const THETA_SEGMENTS = 24; // points around each ring +const HEIGHT_STEPS = 20; // vertical layers + +// Colour thresholds — degrees °C above the bin's upper threshold +const YELLOW_DELTA = 5; // at this far above threshold → full yellow +const RED_DELTA = 10; // at this far above threshold → full red + +// IDW power — higher = sharper transitions between nodes (2 is standard) +const IDW_POWER = 2; + +// Mesh appearance +const OPACITY = 0.55; + +// ----------------------------------------------------------------------- +// COLOUR HELPERS +// ----------------------------------------------------------------------- + +// Returns 0 (green) → 1 (yellow) → 2 (red) based on how far above +// the upper threshold the interpolated temperature is. +// Everything at or below upper threshold = 0. +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; +} + +// Maps heat value [0–2] to RGB. +// 0 = green (#52c41a) +// 1 = yellow (#fadb14) +// 2 = red (#ff4d4f) +function heatToRGB(heat: number): [number, number, number] { + if (heat <= 0) return [0.322, 0.761, 0.102]; // green + + if (heat <= 1) { + // green → yellow + const t = heat; + return [ + 0.322 + (0.980 - 0.322) * t, // R + 0.761 + (0.859 - 0.761) * t, // G + 0.102 + (0.078 - 0.102) * t, // B + ]; + } + + // yellow → red + const t = heat - 1; + return [ + 0.980 + (1.000 - 0.980) * t, // R + 0.859 + (0.302 - 0.859) * t, // G + 0.078 + (0.310 - 0.078) * t, // B + ]; +} + +// ----------------------------------------------------------------------- +// IDW TEMPERATURE INTERPOLATION +// ----------------------------------------------------------------------- + +interface TempAnchor { + x: number; y: number; z: number; + celcius: number; +} + +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; // exactly on a node + + const weight = 1 / Math.pow(distSq, power / 2); + totalWeight += weight; + weightedSum += a.celcius * weight; + } + + return totalWeight === 0 ? 0 : weightedSum / totalWeight; +} + +// ----------------------------------------------------------------------- +// COMPONENT +// ----------------------------------------------------------------------- + +export default function TempHeatMap(props: Props) { + const { bin, nodes } = props; + + const binRadius = bin.diameter() / 2; + const sidewallHeight = bin.sidewallHeight(); + const hopperHeight = bin.hopperHeight() ?? 0; + const upperThreshold = bin.upperTempThreshold(); + const sidewallBaseY = -sidewallHeight / 2; + const hopperTipY = sidewallBaseY - hopperHeight; + + // Taper radius inside the hopper cone + const maxRadiusAtY = (y: number): number => { + if (y >= sidewallBaseY) return binRadius; + if (hopperHeight <= 0 || y <= hopperTipY) return 0; + return binRadius * ((y - hopperTipY) / hopperHeight); + }; + + // Only use in-grain, non-excluded nodes as temperature anchors + const anchors = useMemo(() => + 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] + ); + + // Top of grain — highest in-grain node Y + const maxGrainY = useMemo(() => { + let maxY = sidewallBaseY; + for (const a of anchors) { + if (a.y > maxY) maxY = a.y; + } + return maxY; + }, [anchors, sidewallBaseY]); + + // ----------------------------------------------------------------------- + // BUILD GEOMETRY + // ----------------------------------------------------------------------- + const geometry = useMemo(() => { + if (anchors.length === 0) return null; + + // ------------------------------------------------------------------- + // 1. Sample the cylindrical grid + // ------------------------------------------------------------------- + // Layout: center column + RADIAL_RINGS rings, each with THETA_SEGMENTS + // vertices, stacked HEIGHT_STEPS times vertically. + // + // Vertex index scheme: + // layer * pointsPerLayer + ringOffset + // where ringOffset: 0 = center, 1..N = ring vertices + + const pointsPerLayer = 1 + RADIAL_RINGS * THETA_SEGMENTS; + // +1 for the optional hopper tip vertex (unused for flat-bottom bins) + const totalVerts = HEIGHT_STEPS * pointsPerLayer + 1; + + const positions = new Float32Array(totalVerts * 3); + const colors = new Float32Array(totalVerts * 3); + + // Grain bottom Y — bottom of the grain, either hopper tip or sidewall base + // For hopper bins, starting exactly at hopperTipY causes the entire + // bottom layer to collapse to radius=0 (degenerate triangles that + // disappear when viewed from below). Instead start one HEIGHT_STEPS + // increment above the tip so the bottom layer always has a visible + // radius, then add a separate tip vertex that fans down to a point. + const rawBottomY = hopperHeight > 0 ? hopperTipY : sidewallBaseY; + const grainBottomY = hopperHeight > 0 + ? hopperTipY + (maxGrainY - hopperTipY) / HEIGHT_STEPS + : sidewallBaseY; + const grainHeight = maxGrainY - grainBottomY; + + if (grainHeight <= 0) return null; + + for (let hStep = 0; hStep < HEIGHT_STEPS; hStep++) { + const t = hStep / (HEIGHT_STEPS - 1); + const y = grainBottomY + t * grainHeight; + + const allowedRadius = maxRadiusAtY(y) * 0.97; // slight inset + const layerBase = hStep * pointsPerLayer; + + // Center vertex + const cx = 0, cz = 0; + const centerTemp = idwTemp(cx, y, cz, anchors, IDW_POWER); + const centerHeat = tempToHeat(centerTemp, upperThreshold); + const [cr, cg, cb] = heatToRGB(centerHeat); + + positions[layerBase * 3 + 0] = cx; + positions[layerBase * 3 + 1] = y; + positions[layerBase * 3 + 2] = cz; + colors[layerBase * 3 + 0] = cr; + colors[layerBase * 3 + 1] = cg; + colors[layerBase * 3 + 2] = cb; + + // Ring vertices + for (let ring = 0; ring < RADIAL_RINGS; ring++) { + // sqrt distribution keeps area density even across rings + const rFrac = Math.sqrt((ring + 1) / RADIAL_RINGS); + const r = rFrac * allowedRadius; + + 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 temp = idwTemp(x, y, z, anchors, IDW_POWER); + const heat = tempToHeat(temp, upperThreshold); + const [vr, vg, vb] = heatToRGB(heat); + + const vi = layerBase + 1 + ring * THETA_SEGMENTS + seg; + positions[vi * 3 + 0] = x; + positions[vi * 3 + 1] = y; + positions[vi * 3 + 2] = z; + colors[vi * 3 + 0] = vr; + colors[vi * 3 + 1] = vg; + colors[vi * 3 + 2] = vb; + } + } + } + + // ------------------------------------------------------------------- + // 2. Build triangle indices + // ------------------------------------------------------------------- + // For each pair of adjacent height layers, connect: + // (a) center fan for the innermost ring + // (b) quad strips between adjacent rings + // (c) quad strips between outermost ring top/bottom caps + // We also cap the top and bottom with fans. + + const indices: number[] = []; + + const idx = (hStep: number, ring: number, seg: number): number => { + // ring -1 = center vertex + if (ring < 0) return hStep * pointsPerLayer; + const s = ((seg % THETA_SEGMENTS) + THETA_SEGMENTS) % THETA_SEGMENTS; + return hStep * pointsPerLayer + 1 + ring * THETA_SEGMENTS + s; + }; + + // Side walls — connect each layer to the next + for (let h = 0; h < HEIGHT_STEPS - 1; h++) { + // Center → first ring quads (actually triangles since one side is a point) + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + // tri: center(h), ring0(h,seg), ring0(h,next) + indices.push(idx(h, -1, 0), idx(h, 0, seg), idx(h, 0, next)); + // tri: center(h+1), ring0(h+1,next), ring0(h+1,seg) + indices.push(idx(h + 1, -1, 0), idx(h + 1, 0, next), idx(h + 1, 0, seg)); + // quad connecting the two center fans + indices.push( + idx(h, -1, 0), idx(h + 1, -1, 0), idx(h, 0, seg), + ); + indices.push( + idx(h + 1, -1, 0), idx(h + 1, 0, seg), idx(h, 0, seg), + ); + } + + // Ring-to-ring quads + for (let ring = 0; ring < RADIAL_RINGS - 1; ring++) { + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + + // quad between ring and ring+1 at layer h + const a = idx(h, ring, seg); + const b = idx(h, ring, next); + const c = idx(h, ring + 1, seg); + const d = idx(h, ring + 1, next); + + // quad between ring and ring+1 at layer h+1 + const e = idx(h + 1, ring, seg); + const f = idx(h + 1, ring, next); + const g = idx(h + 1, ring + 1, seg); + const hh = idx(h + 1, ring + 1, next); + + // side face (h → h+1 for this quad) + indices.push(a, e, b); + indices.push(e, f, b); + + // inner ring cap face at layer h + indices.push(a, b, c); + indices.push(b, d, c); + + // inner ring cap face at layer h+1 + indices.push(e, g, f); + indices.push(f, g, hh); + } + } + + // Outermost ring side faces + const outerRing = RADIAL_RINGS - 1; + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + const a = idx(h, outerRing, seg); + const b = idx(h, outerRing, next); + const c = idx(h + 1, outerRing, seg); + const d = idx(h + 1, outerRing, next); + indices.push(a, c, b); + indices.push(b, c, d); + } + } + + // Bottom cap — fan from center to outermost ring + const hBottom = 0; + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + indices.push( + idx(hBottom, -1, 0), + idx(hBottom, RADIAL_RINGS - 1, next), + idx(hBottom, RADIAL_RINGS - 1, seg), + ); + } + + // Top cap + const hTop = HEIGHT_STEPS - 1; + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + indices.push( + idx(hTop, -1, 0), + idx(hTop, RADIAL_RINGS - 1, seg), + idx(hTop, RADIAL_RINGS - 1, next), + ); + } + + // ------------------------------------------------------------------- + // 2b. Hopper tip vertex + fan (only for hopper bins) + // ------------------------------------------------------------------- + // The tip vertex sits at the very last slot in the buffer. + const tipVertexIndex = HEIGHT_STEPS * pointsPerLayer; + + if (hopperHeight > 0) { + const tipTemp = idwTemp(0, rawBottomY, 0, anchors, IDW_POWER); + const tipHeat = tempToHeat(tipTemp, upperThreshold); + const [tr, tg, tb] = heatToRGB(tipHeat); + + positions[tipVertexIndex * 3 + 0] = 0; + positions[tipVertexIndex * 3 + 1] = rawBottomY; // hopperTipY + positions[tipVertexIndex * 3 + 2] = 0; + colors[tipVertexIndex * 3 + 0] = tr; + colors[tipVertexIndex * 3 + 1] = tg; + colors[tipVertexIndex * 3 + 2] = tb; + + // Fan from bottom layer's outermost ring down to the tip point. + // This fills the gap between grainBottomY and hopperTipY. + const hBottom = 0; + const outerRing = RADIAL_RINGS - 1; + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + const a = idx(hBottom, outerRing, seg); + const b = idx(hBottom, outerRing, next); + // Wind so the face is visible from below (tip → b → a) + indices.push(tipVertexIndex, b, a); + } + + // Also fan the bottom layer rings down to the tip for the + // interior of the hopper cone + 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(hBottom, ring, seg); + const b = idx(hBottom, ring, next); + indices.push(tipVertexIndex, b, a); + } + } + // Center to tip + indices.push(tipVertexIndex, idx(hBottom, -1, 0), idx(hBottom, 0, 0)); + } + + // ------------------------------------------------------------------- + // 3. Assemble BufferGeometry + // ------------------------------------------------------------------- + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geo.setAttribute("color", new THREE.BufferAttribute(colors, 3)); + geo.setIndex(indices); + return geo; + }, [anchors, maxGrainY, hopperTipY, sidewallBaseY, binRadius, upperThreshold, hopperHeight]); + + if (!geometry) return null; + + // meshBasicMaterial is used intentionally here instead of meshStandardMaterial: + // - No lighting/normal calculations means face winding direction does not affect + // visibility, so the mesh looks identical from all camera angles including + // below and inside the volume. + // - vertexColors drives all colour — lighting would wash out the green/yellow/red + // gradient anyway depending on light angle. + return ( + + + + ); +} diff --git a/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx b/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx index 0ae8a2e..3cb8cd7 100644 --- a/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx +++ b/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx @@ -260,13 +260,30 @@ export default function GrainCableFill(props: Props) { roughness={1} metalness={0} opacity={grainOpacity} + depthWrite={false} + depthTest={false} + renderOrder={0} /> )} {cylinderFillHeight > 0 && ( - - - - + )} ); @@ -275,9 +292,9 @@ export default function GrainCableFill(props: Props) { // --- Render cable-driven surface --- return ( <> - {/* Interpolated grain surface */} + {/* 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 && ( - + )} @@ -305,6 +323,8 @@ export default function GrainCableFill(props: Props) { roughness={1} opacity={grainOpacity} depthWrite={false} + depthTest={false} + renderOrder={0} /> {/* Hopper fill — always full when grain surface exists above the floor */} @@ -322,6 +342,9 @@ export default function GrainCableFill(props: Props) { roughness={1} metalness={0} opacity={grainOpacity} + depthWrite={false} + depthTest={false} + renderOrder={0} /> )} diff --git a/src/bin/3dView/Systems/Inventory/GrainFillFlat.tsx b/src/bin/3dView/Systems/Inventory/GrainFillFlat.tsx index 29e0db3..e954a1d 100644 --- a/src/bin/3dView/Systems/Inventory/GrainFillFlat.tsx +++ b/src/bin/3dView/Systems/Inventory/GrainFillFlat.tsx @@ -8,6 +8,7 @@ interface Props { sidewallHeight: number; hopperHeight?: number; fillPercent: number; + grainOpacity?: number; } export default function GrainFillFlat(props: Props) { @@ -15,7 +16,8 @@ export default function GrainFillFlat(props: Props) { diameter, sidewallHeight, hopperHeight = 0, - fillPercent + fillPercent, + grainOpacity } = props; const radius = diameter / 2; @@ -99,6 +101,10 @@ export default function GrainFillFlat(props: Props) { colour={grainColour} roughness={1} metalness={0} + opacity={grainOpacity} + depthWrite={false} + depthTest={false} + renderOrder={0} /> )} @@ -116,6 +122,10 @@ export default function GrainFillFlat(props: Props) { colour={grainColour} roughness={1} metalness={0} + opacity={grainOpacity} + depthWrite={false} + depthTest={false} + renderOrder={0} /> )} From 3b62d87d31cf8a296591760d9631a366f0285ff7 Mon Sep 17 00:00:00 2001 From: Carter Date: Wed, 29 Apr 2026 10:20:44 -0600 Subject: [PATCH 057/146] local authentication placeholder --- deploy.sh | 2 +- indexLocal.html | 3 +- package.json | 4 +-- src/app/LocalAuthPlaceholder.tsx | 61 ++++++++++++++++++++++++++++++++ src/utils/auth0Config.ts | 24 +++++++++++++ vite.config.ts | 28 ++++++++------- 6 files changed, 105 insertions(+), 17 deletions(-) create mode 100644 src/app/LocalAuthPlaceholder.tsx create mode 100644 src/utils/auth0Config.ts diff --git a/deploy.sh b/deploy.sh index daa6b63..e03b995 100755 --- a/deploy.sh +++ b/deploy.sh @@ -31,7 +31,7 @@ SSH_OPTS="-i $DEPLOY_SSH_KEY" echo "→ Deploying to $DEPLOY_USER@$DEPLOY_HOST using key $DEPLOY_SSH_KEY" -# 1. Build frontend (uses indexLocal.html — no Crisp; see vite --mode nocrisp) +# 1. Build frontend for LAN / self-contained hosts (indexLocal.html via vite --mode localnet) ( cd "$SCRIPT_DIR" npm run build:local diff --git a/indexLocal.html b/indexLocal.html index d31ab53..af3867c 100644 --- a/indexLocal.html +++ b/indexLocal.html @@ -1,4 +1,5 @@ - + diff --git a/package.json b/package.json index 6dba33e..8531516 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "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 --mode nocrisp", + "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", @@ -16,7 +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_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 nocrisp", + "build:local": "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" }, diff --git a/src/app/LocalAuthPlaceholder.tsx b/src/app/LocalAuthPlaceholder.tsx new file mode 100644 index 0000000..cc05526 --- /dev/null +++ b/src/app/LocalAuthPlaceholder.tsx @@ -0,0 +1,61 @@ +import { Box, Button, CssBaseline, Paper, Stack, Typography } from '@mui/material' +import { getName } from 'services/whiteLabel' + +export type LocalAuthPlaceholderReason = 'unconfigured' | 'insecure_origin' + +interface Props { + /** Why cloud Auth0 was skipped — copy differs when env is set but the page is not a secure context (e.g. http://LAN IP). */ + reason?: LocalAuthPlaceholderReason +} + +/** + * Shown when Auth0 is not used (missing config and/or non–secure context). + * Replace with backend-driven login once local auth is implemented. + */ +export default function LocalAuthPlaceholder(props: Props) { + const { reason = 'unconfigured' } = props + const productName = getName() + + const explanation = + reason === 'insecure_origin' + ? 'This URL is not a secure context for cloud sign-in (Auth0 requires HTTPS, localhost, or 127.0.0.1). Use local account sign-in here instead once it is connected.' + : 'Cloud sign-in (Auth0) is not configured for this deployment. Local account sign-in will be available here.' + + return ( + <> + + + + + + {productName} + + + {explanation} + + + + + + + Buttons are placeholders until the backend login flow is wired up. + + + + + + ) +} diff --git a/src/utils/auth0Config.ts b/src/utils/auth0Config.ts new file mode 100644 index 0000000..1c5e568 --- /dev/null +++ b/src/utils/auth0Config.ts @@ -0,0 +1,24 @@ +import { getWhitelabel } from 'services/whiteLabel' + +/** True when Auth0 env + whitelabel client ID are present; avoids mounting Auth0Provider on offline / local LAN builds. */ +export function isAuth0Configured(): boolean { + const wl = getWhitelabel() + const domain = String(import.meta.env.VITE_AUTH0_CLIENT_DOMAIN ?? '').trim() + const clientRaw = wl.auth0ClientId ?? import.meta.env.VITE_AUTH0_CLIENT_ID + const clientId = String(clientRaw ?? '').trim() + return domain.length > 0 && clientId.length > 0 +} + +/** + * auth0-spa-js only runs in a "secure context" (HTTPS, http://localhost, http://127.0.0.1, etc.). + * Plain http://192.168.x.x fails — same check as `window.isSecureContext`. + */ +export function isAuth0SpaOriginAllowed(): boolean { + if (typeof window === 'undefined') return true + return window.isSecureContext +} + +/** Mount Auth0 only when credentials exist and the browser will let the SDK run. */ +export function shouldMountAuth0Provider(): boolean { + return isAuth0Configured() && isAuth0SpaOriginAllowed() +} diff --git a/vite.config.ts b/vite.config.ts index bc0fc1b..ed1b6f1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,14 +8,15 @@ import { fileURLToPath } from 'node:url' const rootDir = path.dirname(fileURLToPath(import.meta.url)) -const NO_CRISP_MODE = 'nocrisp' +const LOCALNET_MODE = 'localnet' -function useNoCrispIndexHtml (mode: string, command: string): Plugin { - if (mode !== NO_CRISP_MODE || command !== 'serve') { - return { name: 'use-local-index-html-noop' } +/** Dev server: serve the minimal LAN shell (indexLocal.html) instead of the full index with third-party bootstraps. */ +function useLocalnetShellHtml (mode: string, command: string): Plugin { + if (mode !== LOCALNET_MODE || command !== 'serve') { + return { name: 'localnet-shell-html-noop' } } return { - name: 'use-local-index-html', + name: 'localnet-shell-html', apply: 'serve', transformIndexHtml: { order: 'pre', @@ -26,12 +27,13 @@ function useNoCrispIndexHtml (mode: string, command: string): Plugin { } } -function emitNoCrispIndexAsIndexHtml (mode: string): Plugin { - if (mode !== NO_CRISP_MODE) { - return { name: 'emit-nocrisp-index-as-index-noop' } +/** After build, LAN shell is emitted as indexLocal.html; rename to index.html so nginx and PWA still use /. */ +function emitLocalnetShellAsIndexHtml (mode: string): Plugin { + if (mode !== LOCALNET_MODE) { + return { name: 'emit-localnet-shell-as-index-noop' } } return { - name: 'emit-nocrisp-index-as-index-html', + name: 'emit-localnet-shell-as-index-html', closeBundle () { const outDir = path.join(rootDir, 'build') const from = path.join(outDir, 'indexLocal.html') @@ -46,11 +48,11 @@ function emitNoCrispIndexAsIndexHtml (mode: string): Plugin { // https://vitejs.dev/config/ export default defineConfig(({ command, mode }): UserConfig => { - const useNoCrispIndex = mode === NO_CRISP_MODE + const useLocalnetShell = mode === LOCALNET_MODE return { plugins: [ - useNoCrispIndexHtml(mode, command), - emitNoCrispIndexAsIndexHtml(mode), + useLocalnetShellHtml(mode, command), + emitLocalnetShellAsIndexHtml(mode), react(), tsconfigPaths(), VitePWA({ @@ -99,7 +101,7 @@ export default defineConfig(({ command, mode }): UserConfig => { input: { main: path.join( rootDir, - useNoCrispIndex ? 'indexLocal.html' : 'index.html' + useLocalnetShell ? 'indexLocal.html' : 'index.html' ), }, } From b1c676987e7ddc0e52cc089566a73fc57ce6bf43 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 29 Apr 2026 15:33:35 -0600 Subject: [PATCH 058/146] updated the heatmap and added toggles --- src/bin/3dView/Data/BuildNodeData.ts | 4 +- src/bin/3dView/Scene/Bin3dView.tsx | 23 +- src/bin/3dView/Systems/Cables/CableNode.tsx | 14 +- .../3dView/Systems/Heatmap/HeatMapAlpha.tsx | 452 ---------- .../3dView/Systems/Heatmap/TempHeatMap.tsx | 841 ++++++++++-------- src/pages/Bin.tsx | 46 +- 6 files changed, 531 insertions(+), 849 deletions(-) delete mode 100644 src/bin/3dView/Systems/Heatmap/HeatMapAlpha.tsx diff --git a/src/bin/3dView/Data/BuildNodeData.ts b/src/bin/3dView/Data/BuildNodeData.ts index 9d7de98..2aed727 100644 --- a/src/bin/3dView/Data/BuildNodeData.ts +++ b/src/bin/3dView/Data/BuildNodeData.ts @@ -46,9 +46,9 @@ export function BuildNodeData(cables: CableData[]): NodeData[] { let t = celcius // for testing if(i===0){ - t = 30 + t = 40 } - if (i===1){ + if (i===5){ t = 0 } diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index a704007..a9124c7 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -8,11 +8,10 @@ import { Vector3 } from "three"; import { useMemo } from "react"; import { BuildCableData, CableData } from "../Data/BuildCableData"; import { BuildNodeData, NodeData } from "../Data/BuildNodeData"; -import Heatmap from "../Systems/Heatmap/HeatMapAlpha"; 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 TempHeatMap from "../Systems/Heatmap/TempHeatMap"; //grain heat map +import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; //hot spots interface Props { /** @@ -31,16 +30,24 @@ interface Props { fillPercent?: number nodeClick?: (node: NodeData, cable: CableData) => void /** - * When true, renders the heatmap instead of the grain fill. + * toggles the grain in the bin + */ + showGrain?: boolean + /** + * toggles the heatmap overlay */ showHeatmap?: boolean + /** + * toggles the hotspots in the bin + */ + showHotspots?: boolean } export default function Bin3dView(props: Props){ //this function will generate a 3D model of a bin based on its settings using multiple meshes, cylinder for the body, cone for the roof // and either a cone for the hopper or circle for flat bottom, it is possible to also use lathe geometry for this as well, // it might even work better because we can control the smoothness easier with it being only one mesh rather than 3 - const {bin, scale = 100, fillPercent, nodeClick, showHeatmap = false} = props + const {bin, scale = 100, fillPercent, nodeClick, showHeatmap, showGrain, showHotspots} = props const binCenter = useMemo(() => new Vector3(0, 0, 0), []); const cableData = useMemo(() => BuildCableData(bin), [bin]); const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]); @@ -86,7 +93,7 @@ export default function Bin3dView(props: Props){ /> {/* grain - cylinder*/} - {/* {!showHeatmap && grainInventory()} */} + {showGrain && grainInventory()} {/* cables */} { @@ -97,8 +104,8 @@ export default function Bin3dView(props: Props){ } }} renderOrder={1}/> - {/* */} - + {showHotspots && } + {showHeatmap && } {/* lighting */} diff --git a/src/bin/3dView/Systems/Cables/CableNode.tsx b/src/bin/3dView/Systems/Cables/CableNode.tsx index 782b40f..080422a 100644 --- a/src/bin/3dView/Systems/Cables/CableNode.tsx +++ b/src/bin/3dView/Systems/Cables/CableNode.tsx @@ -72,18 +72,16 @@ export default function CableNode(props: Props) { color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour() }); - if (node.humidity) { - r.push({ - text: `${node.humidity.toFixed(2)}%`, - color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour() - }); - } - if (node.moisture) { r.push({ text: `${node.moisture.toFixed(2)}% EMC`, color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour() }); + } else if (node.humidity) { + r.push({ + text: `${node.humidity.toFixed(2)}%`, + color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour() + }); } return r; @@ -151,7 +149,7 @@ export default function CableNode(props: Props) { 1 makes only strong deviations pop; <1 makes small deviations pop more. - */ - deviationPower?: number - // (reverted) extra perf knobs removed -} - -/** - * this is a work in progress, the heatmap generated may not be accurate so avoid using this component for now - * @param props - * @returns - */ -export default function Heatmap(props: Props){ - const { - bin, - nodes, - opacity = 0.65, // kept for backward compatibility - pointOpacity, - pointSize = 5, - ySlices = 22, - radialRings = 16, - thetaSegments = 28, - wallInsetFactor = 0.99, - samplesPerCell = 1, - jitter = 0.75, - alphaHash = true, - greenOpacityFactor = 0.18, - deviationPower = 0.6, - } = props; - - useThree(); // keep fiber context available if needed later - - const sidewallHeight = bin.sidewallHeight(); - const hopperHeight = bin.hopperHeight() ?? 0; - const sidewallBaseY = -sidewallHeight / 2; - const hopperTipY = sidewallBaseY - hopperHeight; - - const inGrainNodes = useMemo( - () => nodes.filter((n) => n.inGrain && !n.excluded), - [nodes], - ); - - const maxGrainY = useMemo(() => { - let maxY = -Infinity; - for (const n of inGrainNodes) maxY = Math.max(maxY, n.position.y); - return Number.isFinite(maxY) ? maxY : sidewallBaseY; - }, [inGrainNodes, sidewallBaseY]); - - const topNodes = useMemo( - () => nodes.filter((n) => n.topNode && n.inGrain && !n.excluded), - [nodes], - ); - - const anchors = useMemo( - () => - topNodes.map((n) => ({ - x: n.position.x, - z: n.position.z, - y: n.position.y + n.nodeSpacing * 0.5, - })), - [topNodes], - ); - - const wallY = useMemo(() => { - if (anchors.length === 0) return -sidewallHeight / 2; - return anchors.reduce((sum, a) => sum + a.y, 0) / anchors.length; - }, [anchors, sidewallHeight]); - - const idwHeight = ( - x: number, - z: number, - inputAnchors: { x: number; z: number; y: number }[], - power = 2, - ): number => { - let totalWeight = 0; - let weightedY = 0; - - for (const a of inputAnchors) { - const dx = x - a.x; - const dz = z - a.z; - const distSq = dx * dx + dz * dz; - if (distSq < 0.001) return a.y; - const w = 1 / Math.pow(distSq, power / 2); - totalWeight += w; - weightedY += a.y * w; - } - - return weightedY / totalWeight; - }; - - const maxRadiusAtY = (y: number, maxR: number): number => { - if (y >= sidewallBaseY) return maxR; - if (hopperHeight <= 0 || y <= hopperTipY) return 0; - const t = (y - hopperTipY) / hopperHeight; // 0..1 - return maxR * t; - }; - - const grainSurfaceY = (x: number, z: number, rNorm: number): number => { - // If we don't have top nodes, use a flat surface at maxGrainY. - if (anchors.length === 0) return maxGrainY; - - const rawY = idwHeight(x, z, anchors); - - // Match `GrainCableFill` outer-wall taper so switching isn't jarring. - const edgeStart = 0.8; - const blendT = Math.max(0, (rNorm - edgeStart) / (1 - edgeStart)); - const s = blendT * blendT * (3 - 2 * blendT); - const y = rawY * (1 - s) + wallY * s; - return Math.max(-sidewallHeight / 2, Math.min(sidewallHeight / 2, y)); - }; - - const evaluateTemp = (px: number, py: number, pz: number): number | null => { - if (inGrainNodes.length === 0) return null; - - // Inverse-distance weighted interpolation. - // Keep power modest so the field stays smooth. - const IDW_POWER = 2; - let totalWeight = 0; - let weightedSum = 0; - - for (const n of inGrainNodes) { - const dx = px - n.position.x; - const dy = py - n.position.y; - const dz = pz - n.position.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 += n.celcius * weight; - } - - if (totalWeight === 0) return null; - return weightedSum / totalWeight; - }; - - const tempToHeatColor = (temp: number): Color => { - // Match your 2D/point visuals: green in-threshold, fade to red/blue as distance grows. - const lower = bin.lowerTempThreshold(); - const upper = bin.upperTempThreshold(); - - const GREEN = new Color("#52c41a"); - const BLUE = new Color("#3399ff"); - const RED = new Color("#ff4d4f"); - - if (temp >= lower && temp <= upper) return GREEN; - - const distance = temp < lower ? lower - temp : temp - upper; - const intensity = Math.min(1, distance / colourFade); // 0..1 - - // Similar HSL shaping as `TempToColour`, but always returns a color. - const minimumLightness = 0.3; - const lightnessRange = 0.2; - const minimumSaturation = 0.7; - const saturationRange = 0.8; - - const hsl = { h: 0, s: 1, l: 1 }; - (temp < lower ? BLUE : RED).getHSL(hsl); - - const c = new Color(); - c.setHSL( - hsl.h, - saturationRange * intensity + minimumSaturation, - lightnessRange * intensity + minimumLightness, - ); - return c; - }; - - const tempToDeviation = (temp: number): number => { - const lower = bin.lowerTempThreshold(); - const upper = bin.upperTempThreshold(); - if (temp >= lower && temp <= upper) return 0; - const distance = temp < lower ? lower - temp : temp - upper; - return Math.min(1, distance / colourFade); - }; - - const { positions, colors, deviations } = useMemo(() => { - const binR = bin.diameter() / 2; - // Important: points are rendered as *sprites*, so even if the center is inside the wall, - // the visible circle can extend outside. Shrink the sampling radius by ~half pointSize - // so the rendered splats stay within the bin. - const maxR = Math.max(0, binR * wallInsetFactor - pointSize * 0.55); - - const y0 = hopperHeight > 0 ? hopperTipY : sidewallBaseY; - const y1 = Math.max(y0, maxGrainY); - - const pos: number[] = []; - const col: number[] = []; - const dev: number[] = []; - const tmpColor = new Color(); - - const safeYSlices = Math.max(6, Math.floor(ySlices)); - const safeRings = Math.max(4, Math.floor(radialRings)); - const safeTheta = Math.max(12, Math.floor(thetaSegments)); - const safeSamples = Math.max(1, Math.floor(samplesPerCell)); - const j = Math.min(1, Math.max(0, jitter)); - // Deterministic "random" so the cloud doesn't shimmer every render. - const rand01 = (seed: number) => { - // xorshift32 - let x = seed | 0; - x ^= x << 13; - x ^= x >>> 17; - x ^= x << 5; - // convert to [0,1) - return ((x >>> 0) % 1000000) / 1000000; - }; - - for (let yi = 0; yi < safeYSlices; yi++) { - const ty = safeYSlices === 1 ? 0 : yi / (safeYSlices - 1); - const y = y0 + (y1 - y0) * ty; - - const rAtY = maxRadiusAtY(y, maxR); - if (rAtY <= 0.001) continue; - - for (let ring = 0; ring < safeRings; ring++) { - for (let seg = 0; seg < safeTheta; seg++) { - // Cell bounds in polar space - const ring0 = ring / safeRings; - const ring1 = (ring + 1) / safeRings; - const r0 = Math.sqrt(ring0) * rAtY; - const r1 = Math.sqrt(ring1) * rAtY; - - const theta0 = (seg / safeTheta) * Math.PI * 2; - const theta1 = ((seg + 1) / safeTheta) * Math.PI * 2; - - for (let s = 0; s < safeSamples; s++) { - const seed = yi * 73856093 + ring * 19349663 + seg * 83492791 + s * 2654435761; - const u = rand01(seed); - const v = rand01(seed ^ 0x9e3779b9); - - // Jitter inside the cell - const rr = r0 + (r1 - r0) * (j === 0 ? 0.5 : (0.5 + (u - 0.5) * j)); - const tt = theta0 + (theta1 - theta0) * (j === 0 ? 0.5 : (0.5 + (v - 0.5) * j)); - - const x = Math.cos(tt) * rr; - const z = Math.sin(tt) * rr; - - const rNorm = rAtY <= 0 ? 0 : rr / rAtY; - const surfaceY = grainSurfaceY(x, z, rNorm); - if (y > surfaceY) continue; - - const temp = evaluateTemp(x, y, z); - const d0 = temp == null ? 0 : tempToDeviation(temp); - - pos.push(x, y, z); - const c = temp == null ? tmpColor.set("#52c41a") : tempToHeatColor(temp); - col.push(c.r, c.g, c.b); - dev.push(d0); - } - } - } - } - - return { - positions: new Float32Array(pos), - colors: new Float32Array(col), - deviations: new Float32Array(dev), - }; - }, [ - bin, - wallInsetFactor, - hopperHeight, - hopperTipY, - sidewallBaseY, - maxGrainY, - ySlices, - radialRings, - thetaSegments, - anchors, - wallY, - inGrainNodes, - samplesPerCell, - jitter, - deviationPower, - ]); - - const alphaHashedMaterial = useMemo(() => { - return new ShaderMaterial({ - transparent: !alphaHash, - depthTest: true, - depthWrite: alphaHash, - uniforms: { - uOpacity: { value: pointOpacity ?? opacity }, - uSize: { value: pointSize }, - uMaxRadius: { value: (bin.diameter() / 2) * wallInsetFactor }, - uSidewallBaseY: { value: -bin.sidewallHeight() / 2 }, - uHopperHeight: { value: bin.hopperHeight() ?? 0 }, - uAlphaHash: { value: alphaHash ? 1 : 0 }, - uGreenOpacityFactor: { value: Math.min(1, Math.max(0, greenOpacityFactor)) }, - uDeviationPower: { value: Math.max(0.05, deviationPower) }, - }, - vertexShader: ` - uniform float uSize; - varying vec3 vWorldPos; - varying vec3 vColor; - varying float vDev; - attribute vec3 color; - attribute float deviation; - void main() { - vColor = color; - vDev = deviation; - vec4 world = modelMatrix * vec4(position, 1.0); - vWorldPos = world.xyz; - vec4 mvPosition = viewMatrix * world; - float attn = 300.0 / max(1.0, -mvPosition.z); - gl_PointSize = uSize * attn; - gl_Position = projectionMatrix * mvPosition; - } - `, - fragmentShader: ` - precision highp float; - uniform float uOpacity; - uniform float uMaxRadius; - uniform float uSidewallBaseY; - uniform float uHopperHeight; - uniform float uAlphaHash; - uniform float uGreenOpacityFactor; - uniform float uDeviationPower; - varying vec3 vColor; - varying float vDev; - varying vec3 vWorldPos; - // interleaved gradient noise - float ign(vec2 p) { - return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); - } - void main() { - // Hard clip pixels to bin radius at this Y (prevents splats outside wall). - float y = vWorldPos.y; - float sidewallBaseY = uSidewallBaseY; - float hopperHeight = uHopperHeight; - float hopperTipY = sidewallBaseY - hopperHeight; - float maxR; - if (y >= sidewallBaseY) { - maxR = uMaxRadius; - } else if (hopperHeight <= 0.0 || y <= hopperTipY) { - maxR = 0.0; - } else { - float t = (y - hopperTipY) / hopperHeight; - maxR = uMaxRadius * t; - } - float r = length(vWorldPos.xz); - if (r > maxR) discard; - - vec2 p = gl_PointCoord - vec2(0.5); - float d = length(p) * 2.0; - float mask = smoothstep(1.0, 0.0, d); - - float dev = clamp(vDev, 0.0, 1.0); - float devCurve = pow(dev, uDeviationPower); - // 0 => green/in-threshold, 1 => strong deviation - float localOpacityFactor = mix(uGreenOpacityFactor, 1.0, devCurve); - float a = clamp(mask * uOpacity * localOpacityFactor, 0.0, 1.0); - - if (uAlphaHash > 0.5) { - float n = ign(gl_FragCoord.xy); - if (n > a) discard; - gl_FragColor = vec4(vColor, 1.0); - } else { - gl_FragColor = vec4(vColor, a); - } - } - `, - }); - }, [alphaHash, bin, deviationPower, greenOpacityFactor, opacity, pointOpacity, pointSize, wallInsetFactor]); - - // Fallback: normal points (no OIT) - return ( - - - - - - - - ); -} \ No newline at end of file diff --git a/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx b/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx index cbb9d0d..a2ab743 100644 --- a/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx +++ b/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx @@ -2,405 +2,490 @@ import { useMemo } from "react"; import * as THREE from "three"; import { Bin } from "models"; import { NodeData } from "../../Data/BuildNodeData"; - +import React from "react"; + interface Props { bin: Bin; nodes: NodeData[]; } - -// ----------------------------------------------------------------------- -// 🎛️ TUNING KNOBS -// ----------------------------------------------------------------------- - -// Grid resolution — more = smoother but heavier -const RADIAL_RINGS = 12; // rings of sample points from center outward -const THETA_SEGMENTS = 24; // points around each ring -const HEIGHT_STEPS = 20; // vertical layers - -// Colour thresholds — degrees °C above the bin's upper threshold -const YELLOW_DELTA = 5; // at this far above threshold → full yellow -const RED_DELTA = 10; // at this far above threshold → full red - -// IDW power — higher = sharper transitions between nodes (2 is standard) -const IDW_POWER = 2; - -// Mesh appearance -const OPACITY = 0.55; - -// ----------------------------------------------------------------------- -// COLOUR HELPERS -// ----------------------------------------------------------------------- - -// Returns 0 (green) → 1 (yellow) → 2 (red) based on how far above -// the upper threshold the interpolated temperature is. -// Everything at or below upper threshold = 0. -function tempToHeat(temp: number, upper: number): 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; + +// New tuning knobs +const ANGLE_JITTER = 0.2; +const RADIAL_JITTER = 0.05; +const LAYER_TWIST = 0.22; + +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); + + if (delta >= YELLOW_DELTA) { + return 1 + + (delta - YELLOW_DELTA) / + (RED_DELTA - YELLOW_DELTA); + } + return delta / YELLOW_DELTA; } - -// Maps heat value [0–2] to RGB. -// 0 = green (#52c41a) -// 1 = yellow (#fadb14) -// 2 = red (#ff4d4f) -function heatToRGB(heat: number): [number, number, number] { - if (heat <= 0) return [0.322, 0.761, 0.102]; // green - + +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) { - // green → yellow - const t = heat; - return [ - 0.322 + (0.980 - 0.322) * t, // R - 0.761 + (0.859 - 0.761) * t, // G - 0.102 + (0.078 - 0.102) * t, // B - ]; + + 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, + ]; } - - // yellow → red - const t = heat - 1; + + const t = Math.pow(heat-1,0.75); + return [ - 0.980 + (1.000 - 0.980) * t, // R - 0.859 + (0.302 - 0.859) * t, // G - 0.078 + (0.310 - 0.078) * t, // B + YELLOW[0] + (RED[0]-YELLOW[0])*t, + YELLOW[1] + (RED[1]-YELLOW[1])*t, + YELLOW[2] + (RED[2]-YELLOW[2])*t, ]; -} - -// ----------------------------------------------------------------------- -// IDW TEMPERATURE INTERPOLATION -// ----------------------------------------------------------------------- - + } + interface TempAnchor { - x: number; y: number; z: number; - celcius: number; -} - -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; // exactly on a node - - const weight = 1 / Math.pow(distSq, power / 2); - totalWeight += weight; - weightedSum += a.celcius * weight; + x:number; + y:number; + z:number; + celcius:number; + } + + 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; + } - return totalWeight === 0 ? 0 : weightedSum / totalWeight; -} + // deterministic pseudo-random based on indices + function hashNoise(a:number,b:number,c:number){ + const x = Math.sin( + a*127.1 + b*311.7 + c*74.7 + ) * 43758.5453; -// ----------------------------------------------------------------------- -// COMPONENT -// ----------------------------------------------------------------------- - -export default function TempHeatMap(props: Props) { - const { bin, nodes } = props; - - const binRadius = bin.diameter() / 2; - const sidewallHeight = bin.sidewallHeight(); - const hopperHeight = bin.hopperHeight() ?? 0; - const upperThreshold = bin.upperTempThreshold(); - const sidewallBaseY = -sidewallHeight / 2; - const hopperTipY = sidewallBaseY - hopperHeight; - - // Taper radius inside the hopper cone - const maxRadiusAtY = (y: number): number => { - if (y >= sidewallBaseY) return binRadius; - if (hopperHeight <= 0 || y <= hopperTipY) return 0; - return binRadius * ((y - hopperTipY) / hopperHeight); + return (x - Math.floor(x))*2 -1; + } + export default function TempHeatMapGPT({bin,nodes}:Props){ + + const binRadius=bin.diameter()/2; + const sidewallHeight=bin.sidewallHeight(); + const hopperHeight=bin.hopperHeight() ?? 0; + const upperThreshold=bin.upperTempThreshold(); + + const sidewallBaseY=-sidewallHeight/2; + const hopperTipY=sidewallBaseY-hopperHeight; + + const maxRadiusAtY=(y:number)=>{ + + if(y>=sidewallBaseY) + return binRadius; + + if(hopperHeight<=0 || y<=hopperTipY) + return 0; + + return binRadius* + ((y-hopperTipY)/hopperHeight); }; - - // Only use in-grain, non-excluded nodes as temperature anchors - const anchors = useMemo(() => - nodes - .filter(n => n.inGrain && !n.excluded) - .map(n => ({ - x: n.position.x, - y: n.position.y, - z: n.position.z, - celcius: n.celcius, - })), + const anchors=useMemo( + ()=>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] - ); - - // Top of grain — highest in-grain node Y - const maxGrainY = useMemo(() => { - let maxY = sidewallBaseY; - for (const a of anchors) { - if (a.y > maxY) maxY = a.y; - } - return maxY; - }, [anchors, sidewallBaseY]); - - // ----------------------------------------------------------------------- - // BUILD GEOMETRY - // ----------------------------------------------------------------------- - const geometry = useMemo(() => { - if (anchors.length === 0) return null; - - // ------------------------------------------------------------------- - // 1. Sample the cylindrical grid - // ------------------------------------------------------------------- - // Layout: center column + RADIAL_RINGS rings, each with THETA_SEGMENTS - // vertices, stacked HEIGHT_STEPS times vertically. - // - // Vertex index scheme: - // layer * pointsPerLayer + ringOffset - // where ringOffset: 0 = center, 1..N = ring vertices - - const pointsPerLayer = 1 + RADIAL_RINGS * THETA_SEGMENTS; - // +1 for the optional hopper tip vertex (unused for flat-bottom bins) - const totalVerts = HEIGHT_STEPS * pointsPerLayer + 1; - - const positions = new Float32Array(totalVerts * 3); - const colors = new Float32Array(totalVerts * 3); - - // Grain bottom Y — bottom of the grain, either hopper tip or sidewall base - // For hopper bins, starting exactly at hopperTipY causes the entire - // bottom layer to collapse to radius=0 (degenerate triangles that - // disappear when viewed from below). Instead start one HEIGHT_STEPS - // increment above the tip so the bottom layer always has a visible - // radius, then add a separate tip vertex that fans down to a point. - const rawBottomY = hopperHeight > 0 ? hopperTipY : sidewallBaseY; - const grainBottomY = hopperHeight > 0 - ? hopperTipY + (maxGrainY - hopperTipY) / HEIGHT_STEPS + ); + + const maxGrainY=useMemo(()=>{ + let maxY=sidewallBaseY; + + for(const a of anchors) + if(a.y>maxY) + maxY=a.y; + + return maxY; + },[anchors,sidewallBaseY]); + + const geometry=useMemo(()=>{ + + if(!anchors.length) + return null; + + const pointsPerLayer= + 1 + RADIAL_RINGS*THETA_SEGMENTS; + + const totalVerts= + HEIGHT_STEPS*pointsPerLayer +1; + + const positions= + new Float32Array(totalVerts*3); + + const colors= + new Float32Array(totalVerts*3); + + const heats=new Float32Array(totalVerts); + + const rawBottomY= + hopperHeight>0 + ? hopperTipY + : sidewallBaseY; + + const grainBottomY= + hopperHeight>0 + ? hopperTipY + + (maxGrainY-hopperTipY)/HEIGHT_STEPS : sidewallBaseY; - const grainHeight = maxGrainY - grainBottomY; + + const grainHeight= + maxGrainY-grainBottomY; + + if(grainHeight<=0) + return null; + + for(let hStep=0; hStep{ + + 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 { - // ring -1 = center vertex - if (ring < 0) return hStep * pointsPerLayer; - const s = ((seg % THETA_SEGMENTS) + THETA_SEGMENTS) % THETA_SEGMENTS; - return hStep * pointsPerLayer + 1 + ring * THETA_SEGMENTS + s; - }; - - // Side walls — connect each layer to the next - for (let h = 0; h < HEIGHT_STEPS - 1; h++) { - // Center → first ring quads (actually triangles since one side is a point) - for (let seg = 0; seg < THETA_SEGMENTS; seg++) { - const next = (seg + 1) % THETA_SEGMENTS; - // tri: center(h), ring0(h,seg), ring0(h,next) - indices.push(idx(h, -1, 0), idx(h, 0, seg), idx(h, 0, next)); - // tri: center(h+1), ring0(h+1,next), ring0(h+1,seg) - indices.push(idx(h + 1, -1, 0), idx(h + 1, 0, next), idx(h + 1, 0, seg)); - // quad connecting the two center fans - indices.push( - idx(h, -1, 0), idx(h + 1, -1, 0), idx(h, 0, seg), - ); - indices.push( - idx(h + 1, -1, 0), idx(h + 1, 0, seg), idx(h, 0, seg), - ); - } - - // Ring-to-ring quads - for (let ring = 0; ring < RADIAL_RINGS - 1; ring++) { - for (let seg = 0; seg < THETA_SEGMENTS; seg++) { - const next = (seg + 1) % THETA_SEGMENTS; - - // quad between ring and ring+1 at layer h - const a = idx(h, ring, seg); - const b = idx(h, ring, next); - const c = idx(h, ring + 1, seg); - const d = idx(h, ring + 1, next); - - // quad between ring and ring+1 at layer h+1 - const e = idx(h + 1, ring, seg); - const f = idx(h + 1, ring, next); - const g = idx(h + 1, ring + 1, seg); - const hh = idx(h + 1, ring + 1, next); - - // side face (h → h+1 for this quad) - indices.push(a, e, b); - indices.push(e, f, b); - - // inner ring cap face at layer h - indices.push(a, b, c); - indices.push(b, d, c); - - // inner ring cap face at layer h+1 - indices.push(e, g, f); - indices.push(f, g, hh); - } - } - - // Outermost ring side faces - const outerRing = RADIAL_RINGS - 1; - for (let seg = 0; seg < THETA_SEGMENTS; seg++) { - const next = (seg + 1) % THETA_SEGMENTS; - const a = idx(h, outerRing, seg); - const b = idx(h, outerRing, next); - const c = idx(h + 1, outerRing, seg); - const d = idx(h + 1, outerRing, next); - indices.push(a, c, b); - indices.push(b, c, d); - } - } - - // Bottom cap — fan from center to outermost ring - const hBottom = 0; - for (let seg = 0; seg < THETA_SEGMENTS; seg++) { - const next = (seg + 1) % THETA_SEGMENTS; - indices.push( - idx(hBottom, -1, 0), - idx(hBottom, RADIAL_RINGS - 1, next), - idx(hBottom, RADIAL_RINGS - 1, seg), - ); - } - - // Top cap - const hTop = HEIGHT_STEPS - 1; - for (let seg = 0; seg < THETA_SEGMENTS; seg++) { - const next = (seg + 1) % THETA_SEGMENTS; - indices.push( - idx(hTop, -1, 0), - idx(hTop, RADIAL_RINGS - 1, seg), - idx(hTop, RADIAL_RINGS - 1, next), - ); - } - - // ------------------------------------------------------------------- - // 2b. Hopper tip vertex + fan (only for hopper bins) - // ------------------------------------------------------------------- - // The tip vertex sits at the very last slot in the buffer. - const tipVertexIndex = HEIGHT_STEPS * pointsPerLayer; - - if (hopperHeight > 0) { - const tipTemp = idwTemp(0, rawBottomY, 0, anchors, IDW_POWER); - const tipHeat = tempToHeat(tipTemp, upperThreshold); - const [tr, tg, tb] = heatToRGB(tipHeat); - - positions[tipVertexIndex * 3 + 0] = 0; - positions[tipVertexIndex * 3 + 1] = rawBottomY; // hopperTipY - positions[tipVertexIndex * 3 + 2] = 0; - colors[tipVertexIndex * 3 + 0] = tr; - colors[tipVertexIndex * 3 + 1] = tg; - colors[tipVertexIndex * 3 + 2] = tb; - - // Fan from bottom layer's outermost ring down to the tip point. - // This fills the gap between grainBottomY and hopperTipY. - const hBottom = 0; - const outerRing = RADIAL_RINGS - 1; - for (let seg = 0; seg < THETA_SEGMENTS; seg++) { - const next = (seg + 1) % THETA_SEGMENTS; - const a = idx(hBottom, outerRing, seg); - const b = idx(hBottom, outerRing, next); - // Wind so the face is visible from below (tip → b → a) - indices.push(tipVertexIndex, b, a); - } - - // Also fan the bottom layer rings down to the tip for the - // interior of the hopper cone - 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(hBottom, ring, seg); - const b = idx(hBottom, ring, next); - indices.push(tipVertexIndex, b, a); - } - } - // Center to tip - indices.push(tipVertexIndex, idx(hBottom, -1, 0), idx(hBottom, 0, 0)); - } - - // ------------------------------------------------------------------- - // 3. Assemble BufferGeometry - // ------------------------------------------------------------------- - const geo = new THREE.BufferGeometry(); - geo.setAttribute("position", new THREE.BufferAttribute(positions, 3)); - geo.setAttribute("color", new THREE.BufferAttribute(colors, 3)); - geo.setIndex(indices); - return geo; - }, [anchors, maxGrainY, hopperTipY, sidewallBaseY, binRadius, upperThreshold, hopperHeight]); - - if (!geometry) return null; - - // meshBasicMaterial is used intentionally here instead of meshStandardMaterial: - // - No lighting/normal calculations means face winding direction does not affect - // visibility, so the mesh looks identical from all camera angles including - // below and inside the volume. - // - vertexColors drives all colour — lighting would wash out the green/yellow/red - // gradient anyway depending on light angle. - return ( - - - + // indices.push(a,e,b); + // indices.push(e,f,b); + pushTri(a,e,b); + pushTri(e,f,b); + } + } + + //center fill + for (let seg=0; seg0){ + + const tipTemp=idwTemp( + 0, + rawBottomY, + 0, + anchors, + IDW_POWER + ); + + const tipHeat= + tempToHeat( + tipTemp, + upperThreshold + ); + + const [tr,tg,tb]= + heatToRGB(tipHeat); + + positions[tipVertexIndex*3]=0; + positions[tipVertexIndex*3+1]=rawBottomY; + positions[tipVertexIndex*3+2]=0; + + colors[tipVertexIndex*3]=tr; + colors[tipVertexIndex*3+1]=tg; + colors[tipVertexIndex*3+2]=tb; + const outerRing=RADIAL_RINGS-1; + + for(let seg=0; seg + + + + + + + + + + + + + ); + +} \ No newline at end of file diff --git a/src/pages/Bin.tsx b/src/pages/Bin.tsx index 78e6c38..7531ab0 100644 --- a/src/pages/Bin.tsx +++ b/src/pages/Bin.tsx @@ -22,6 +22,9 @@ import { AccordionDetails, Typography, TextField, + RadioGroup, + Checkbox, + FormControlLabel, } from "@mui/material"; import BinActions from "bin/BinActions"; import BinHistory from "bin/BinHistory"; @@ -212,6 +215,11 @@ export default function Bin(props: Props) { const [binPresets, setBinPresets] = useState([]); const [missedReadings, setMissedReadings] = useState(0); + //3d bin variables/toggle + const [showGrain, setShowGrain] = useState(false) + const [showHotspots, setShowHotspots] = useState(false) + const [showHeatmap, setShowHeatmap] = useState(false) + const handleChange = (_event: React.ChangeEvent<{}>, newValue: number) => { setValue(newValue); }; @@ -816,7 +824,43 @@ export default function Bin(props: Props) { setFillPercent(+e.target.value) }} /> - + { + setShowGrain(checked); + }} + /> + } + label={"Inventory Toggle"} + /> + { + setShowHotspots(checked); + }} + /> + } + label={"Hot Spots"} + /> + { + setShowHeatmap(checked); + }} + /> + } + label={"Heatmap"} + /> +
From 874be1d8b7213d042145738d3cb468902821ab27 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Thu, 30 Apr 2026 09:42:02 -0600 Subject: [PATCH 059/146] changed the heatmap limit to use the grain height --- src/3dModels/Shapes/BaseMesh.tsx | 4 +- src/bin/3dView/Scene/Bin3dView.tsx | 159 ++-- .../3dView/Systems/Heatmap/TempHeatMap.tsx | 851 +++++++++--------- 3 files changed, 489 insertions(+), 525 deletions(-) diff --git a/src/3dModels/Shapes/BaseMesh.tsx b/src/3dModels/Shapes/BaseMesh.tsx index e06d622..73ea434 100644 --- a/src/3dModels/Shapes/BaseMesh.tsx +++ b/src/3dModels/Shapes/BaseMesh.tsx @@ -79,9 +79,9 @@ export default function BaseMesh(props: BaseMeshProps) { }; return ( - + {/* Main surface */} - + {buildMaterial()} diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index a9124c7..68b5373 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -1,3 +1,4 @@ + import OrbitCameraControls from "3dModels/CameraControls/OrbitCameraControls"; import { Canvas } from "@react-three/fiber"; import { Bin } from "models"; @@ -10,53 +11,56 @@ 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"; //grain heat map -import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; //hot spots - +import TempHeatMap from "../Systems/Heatmap/TempHeatMap"; +import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; + interface Props { - /** - * The bin to generate a 3D model of - */ bin: Bin - /** - * The scale to apply to the bin dimensions - * ie 100 would make the bin 1:100 scale - * @default 100 - */ scale: number - /** - * optional: the percent of the bin to fill using a level top, this will be used with manual and lidar controls - */ fillPercent?: number nodeClick?: (node: NodeData, cable: CableData) => void - /** - * toggles the grain in the bin - */ showGrain?: boolean - /** - * toggles the heatmap overlay - */ showHeatmap?: boolean - /** - * toggles the hotspots in the bin - */ showHotspots?: boolean } - -export default function Bin3dView(props: Props){ - //this function will generate a 3D model of a bin based on its settings using multiple meshes, cylinder for the body, cone for the roof - // and either a cone for the hopper or circle for flat bottom, it is possible to also use lathe geometry for this as well, - // it might even work better because we can control the smoothness easier with it being only one mesh rather than 3 - const {bin, scale = 100, fillPercent, nodeClick, showHeatmap, showGrain, showHotspots} = props + +export default function Bin3dView(props: Props) { + const { bin, scale = 100, fillPercent, nodeClick, showHeatmap, showGrain, showHotspots } = props + const binCenter = useMemo(() => new Vector3(0, 0, 0), []); const cableData = useMemo(() => BuildCableData(bin), [bin]); - const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]); - + 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. + // + // For flat fill percent, we convert the fill percent to a Y coordinate + // using the same volume math as GrainFillFlat and pass it as flatMaxY. + const flatMaxY = useMemo(() => { + if (isCableInventory || fillPercent === undefined) return undefined; + + const radius = bin.diameter() / 2; + const sidewallH = bin.sidewallHeight(); + const hopperH = bin.hopperHeight() ?? 0; + const cylVolume = Math.PI * radius * radius * sidewallH; + const hopperVol = hopperH > 0 ? (1 / 3) * Math.PI * radius * radius * hopperH : 0; + const filled = (cylVolume + hopperVol) * fillPercent; + + const cylinderFillH = hopperH > 0 && filled <= hopperVol + ? 0 + : Math.max(0, (filled - hopperVol) / (Math.PI * radius * radius)); + + return -sidewallH / 2 + cylinderFillH; + }, [isCableInventory, fillPercent, bin]); + const grainInventory = () => { - if(bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_AUTOMATIC || - bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_HYBRID_CABLE){ + if (isCableInventory) { return ( - ) - }else if (fillPercent){ - + } else if (fillPercent) { + return ( + + ) } } - + return ( - - - + + - - {/* grain - cylinder*/} + {showGrain && grainInventory()} - {/* cables */} - { - // console.log(node) - // console.log(cable) - if(nodeClick){ - nodeClick(node, cable) - } + if (nodeClick) nodeClick(node, cable) }} - renderOrder={1}/> - {showHotspots && } - {showHeatmap && } - - - {/* lighting */} - - - - - - - + renderOrder={1} + /> + + {showHotspots && } + + {showHeatmap && ( + + )} + + + + + + + + + ) -} \ No newline at end of file +} diff --git a/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx b/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx index a2ab743..ee6e46c 100644 --- a/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx +++ b/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx @@ -3,489 +3,440 @@ import * as THREE from "three"; import { Bin } from "models"; import { NodeData } from "../../Data/BuildNodeData"; import React from "react"; - + interface Props { bin: Bin; nodes: NodeData[]; + /** + * For flat fill percent inventory — a scalar Y ceiling for the heatmap top. + * Used when there are no cable top nodes to drive a surface. + */ + flatMaxY?: number; } - + const RADIAL_RINGS = 20; const THETA_SEGMENTS = 40; const HEIGHT_STEPS = 28; - + const YELLOW_DELTA = 5; const RED_DELTA = 10; - + const IDW_POWER = 4; const RED_OPACITY = 0.3; const GREEN_OPACITY = 0.3; const YELLOW_OPACITY = 0.8; - -// New tuning knobs + const ANGLE_JITTER = 0.2; const RADIAL_JITTER = 0.05; const LAYER_TWIST = 0.22; - -function tempToHeat(temp:number, upper:number):number { + +// 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); - } - + 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; - + +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, 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); - + + 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, + 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; - } - - 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; + 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; - } - // deterministic pseudo-random based on indices - function hashNoise(a:number,b:number,c:number){ - const x = Math.sin( - a*127.1 + b*311.7 + c*74.7 - ) * 43758.5453; + return totalWeight === 0 ? 0 : weightedSum / totalWeight; +} - return (x - Math.floor(x))*2 -1; - } - export default function TempHeatMapGPT({bin,nodes}:Props){ - - const binRadius=bin.diameter()/2; - const sidewallHeight=bin.sidewallHeight(); - const hopperHeight=bin.hopperHeight() ?? 0; - const upperThreshold=bin.upperTempThreshold(); - - const sidewallBaseY=-sidewallHeight/2; - const hopperTipY=sidewallBaseY-hopperHeight; - - const maxRadiusAtY=(y:number)=>{ - - if(y>=sidewallBaseY) - return binRadius; - - if(hopperHeight<=0 || y<=hopperTipY) - return 0; - - return binRadius* - ((y-hopperTipY)/hopperHeight); +// 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({ bin, nodes, flatMaxY }: Props) { + const binRadius = bin.diameter() / 2; + const sidewallHeight = bin.sidewallHeight(); + const hopperHeight = bin.hopperHeight() ?? 0; + const upperThreshold = bin.upperTempThreshold(); + + const sidewallBaseY = -sidewallHeight / 2; + const hopperTipY = sidewallBaseY - hopperHeight; + + const maxRadiusAtY = (y: number) => { + if (y >= sidewallBaseY) return binRadius; + if (hopperHeight <= 0 || y <= hopperTipY) return 0; + return binRadius * ((y - hopperTipY) / hopperHeight); }; - const anchors=useMemo( - ()=>nodes - .filter(n=>n.inGrain && !n.excluded) - .map(n=>({ - x:n.position.x, - y:n.position.y, - z:n.position.z, - celcius:n.celcius, - })), + + // Temperature anchors — all in-grain nodes + const tempAnchors = useMemo( + () => + 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] - ); - - const maxGrainY=useMemo(()=>{ - let maxY=sidewallBaseY; - - for(const a of anchors) - if(a.y>maxY) - maxY=a.y; - - return maxY; - },[anchors,sidewallBaseY]); - - const geometry=useMemo(()=>{ - - if(!anchors.length) - return null; - - const pointsPerLayer= - 1 + RADIAL_RINGS*THETA_SEGMENTS; - - const totalVerts= - HEIGHT_STEPS*pointsPerLayer +1; - - const positions= - new Float32Array(totalVerts*3); - - const colors= - new Float32Array(totalVerts*3); - - const heats=new Float32Array(totalVerts); - - const rawBottomY= - hopperHeight>0 - ? hopperTipY - : sidewallBaseY; - - const grainBottomY= - hopperHeight>0 - ? hopperTipY + - (maxGrainY-hopperTipY)/HEIGHT_STEPS + ); + + // Surface anchors — top nodes only, Y offset by half spacing + // Matches GrainCableFill's anchor computation exactly. + const surfaceAnchors = useMemo( + () => + nodes + .filter(n => n.topNode && n.inGrain && !n.excluded) + .map(n => ({ + x: n.position.x, + z: n.position.z, + y: n.position.y + n.nodeSpacing * 0.5, + })), + [nodes] + ); + + // Wall clamp Y — average of surface anchor heights. + // Prevents the surface from piling up at the bin wall where there are no cables. + // Matches GrainCableFill's wallY computation. + const wallY = useMemo(() => { + if (surfaceAnchors.length === 0) return flatMaxY ?? sidewallBaseY; + return surfaceAnchors.reduce((sum, a) => sum + a.y, 0) / surfaceAnchors.length; + }, [surfaceAnchors, flatMaxY, sidewallBaseY]); + + // For each (x, z) position, returns the Y ceiling of the grain surface. + // Uses cable surface IDW when top nodes exist, falls back to flatMaxY. + const getSurfaceY = (x: number, z: number): number => { + if (surfaceAnchors.length > 0) { + // Outermost ring clamps to wallY — matches GrainCableFill + const raw = idwSurfaceY(x, z, surfaceAnchors, SURFACE_IDW_POWER); + return Math.max(sidewallBaseY, Math.min(sidewallHeight / 2, raw)); + } + return flatMaxY ?? sidewallBaseY; + }; + + // Global max Y — highest point of the surface (used for grid bottom calc) + const maxGrainY = useMemo(() => { + if (surfaceAnchors.length > 0) { + return Math.max(...surfaceAnchors.map(a => a.y), wallY); + } + return flatMaxY ?? sidewallBaseY; + }, [surfaceAnchors, wallY, flatMaxY, sidewallBaseY]); + + const geometry = useMemo(() => { + if (!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 rawBottomY = hopperHeight > 0 ? hopperTipY : sidewallBaseY; + const grainBottomY = hopperHeight > 0 + ? hopperTipY + (maxGrainY - hopperTipY) / HEIGHT_STEPS : sidewallBaseY; - - const grainHeight= - maxGrainY-grainBottomY; - - if(grainHeight<=0) - return null; - - for(let hStep=0; hStep{ - - 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; h0){ - - const tipTemp=idwTemp( - 0, - rawBottomY, - 0, - anchors, - IDW_POWER - ); - - const tipHeat= - tempToHeat( - tipTemp, - upperThreshold - ); - - const [tr,tg,tb]= - heatToRGB(tipHeat); - - positions[tipVertexIndex*3]=0; - positions[tipVertexIndex*3+1]=rawBottomY; - positions[tipVertexIndex*3+2]=0; - - colors[tipVertexIndex*3]=tr; - colors[tipVertexIndex*3+1]=tg; - colors[tipVertexIndex*3+2]=tb; - const outerRing=RADIAL_RINGS-1; - - for(let seg=0; seg { + if (ring < 0) return hStep * pointsPerLayer; + const s = ((seg % THETA_SEGMENTS) + THETA_SEGMENTS) % THETA_SEGMENTS; + return hStep * pointsPerLayer + 1 + ring * THETA_SEGMENTS + s; + }; + + const green: number[] = []; + const yellow: number[] = []; + const red: number[] = []; + + function pushTri(a: number, b: number, c: number) { + const avg = (heats[a] + heats[b] + heats[c]) / 3; + if (avg < 1) green.push(a, b, c); + else if (avg < 2) yellow.push(a, b, c); + else red.push(a, b, c); + } + + for (let h = 0; h < HEIGHT_STEPS - 1; h++) { + for (let ring = 0; ring < RADIAL_RINGS - 1; ring++) { + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + const a = idx(h, ring, seg); + const b = idx(h, ring, next); + const e = idx(h + 1, ring, seg); + const f = idx(h + 1, ring, next); + pushTri(a, e, b); + pushTri(e, f, b); + } + } + + // center fill + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + const center0 = idx(h, -1, 0); + const center1 = idx(h + 1, -1, 0); + const a = idx(h, 0, seg); + const b = idx(h, 0, next); + const c = idx(h + 1, 0, seg); + const d = idx(h + 1, 0, next); + pushTri(center0, c, a); + pushTri(center0, center1, c); + pushTri(a, c, b); + pushTri(b, c, d); + } + } + + // Hopper tip + const tipVertexIndex = HEIGHT_STEPS * pointsPerLayer; + if (hopperHeight > 0) { + const tipTemp = idwTemp(0, rawBottomY, 0, tempAnchors, IDW_POWER); + const tipHeat = tempToHeat(tipTemp, upperThreshold); + const [tr, tg, tb] = heatToRGB(tipHeat); + + positions[tipVertexIndex * 3] = 0; + positions[tipVertexIndex * 3 + 1] = rawBottomY; + positions[tipVertexIndex * 3 + 2] = 0; + colors[tipVertexIndex * 3] = tr; + colors[tipVertexIndex * 3 + 1] = tg; + colors[tipVertexIndex * 3 + 2] = tb; + heats[tipVertexIndex] = tipHeat; + + const outerRing = RADIAL_RINGS - 1; + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + pushTri(tipVertexIndex, idx(0, outerRing, next), idx(0, outerRing, seg)); + } + } + + function makeGeo(indices: number[]) { + const g = new THREE.BufferGeometry(); + g.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + g.setAttribute("color", new THREE.BufferAttribute(colors, 3)); + g.setIndex(indices); + return g; + } + + return { + green: makeGeo(green), + yellow: makeGeo(yellow), + red: makeGeo(red), + }; + }, [ + tempAnchors, + surfaceAnchors, + wallY, + maxGrainY, + hopperTipY, + sidewallBaseY, + binRadius, + upperThreshold, + hopperHeight, + flatMaxY, + ]); + + if (!geometry) return null; + + return ( + + + + + + + + + + + + + ); - g.setAttribute( - 'color', - new THREE.BufferAttribute(colors,3) - ); - g.setIndex(indices); - return g; - } - - return { - green:makeGeo(green), - yellow:makeGeo(yellow), - red:makeGeo(red) - }; - - },[ - anchors, - maxGrainY, - hopperTipY, - sidewallBaseY, - binRadius, - upperThreshold, - hopperHeight, - ]); - - if(!geometry) - return null; - return ( - - - - - - - - - - - - - - ); - -} \ No newline at end of file +} From b3cf5b5e83b47f52d7df2f7f7e784ce032898d95 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Thu, 30 Apr 2026 09:43:19 -0600 Subject: [PATCH 060/146] took out hardcoding of nodes for testing --- src/bin/3dView/Data/BuildNodeData.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/bin/3dView/Data/BuildNodeData.ts b/src/bin/3dView/Data/BuildNodeData.ts index 2aed727..4103ce5 100644 --- a/src/bin/3dView/Data/BuildNodeData.ts +++ b/src/bin/3dView/Data/BuildNodeData.ts @@ -45,12 +45,12 @@ export function BuildNodeData(cables: CableData[]): NodeData[] { const moisture = grainCable.moisture[i] || undefined; let t = celcius // for testing - if(i===0){ - t = 40 - } - if (i===5){ - t = 0 - } + // if(i===0){ + // t = 40 + // } + // if (i===5){ + // t = 0 + // } nodeData.push({ cableIndex: cableIndex, From 4b2c67dfd9dd8ded8d67f1f6a434e773cea31a3a Mon Sep 17 00:00:00 2001 From: csawatzky Date: Fri, 1 May 2026 13:11:12 -0600 Subject: [PATCH 061/146] 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 --- src/3dModels/CameraControls/CameraOverlay.tsx | 90 ++++ .../CameraControls/OrbitCameraControls.tsx | 203 ++++---- src/bin/3dView/Scene/Bin3dView.tsx | 75 ++- src/bin/3dView/Systems/Cables/BinCables.tsx | 6 +- src/bin/3dView/Systems/Cables/CableNode.tsx | 96 ++-- src/bin/3dView/Systems/Cables/GrainCable.tsx | 6 +- src/bin/binSummary/BinSummary.tsx | 76 +++ .../binSummary/components/bin3dVisualizer.tsx | 105 ++++ src/navigation/Router.tsx | 8 + src/pages/Bin.tsx | 47 -- src/pages/BinV2.tsx | 482 ++++++++++++++++++ 11 files changed, 974 insertions(+), 220 deletions(-) create mode 100644 src/3dModels/CameraControls/CameraOverlay.tsx create mode 100644 src/bin/binSummary/BinSummary.tsx create mode 100644 src/bin/binSummary/components/bin3dVisualizer.tsx create mode 100644 src/pages/BinV2.tsx diff --git a/src/3dModels/CameraControls/CameraOverlay.tsx b/src/3dModels/CameraControls/CameraOverlay.tsx new file mode 100644 index 0000000..080aab9 --- /dev/null +++ b/src/3dModels/CameraControls/CameraOverlay.tsx @@ -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 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(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( +
+ {showResetButton && ( + + )} +
, + container + ); +} \ No newline at end of file diff --git a/src/3dModels/CameraControls/OrbitCameraControls.tsx b/src/3dModels/CameraControls/OrbitCameraControls.tsx index 0385cdd..d145caa 100644 --- a/src/3dModels/CameraControls/OrbitCameraControls.tsx +++ b/src/3dModels/CameraControls/OrbitCameraControls.tsx @@ -1,6 +1,7 @@ +import React from "react"; import { useThree } from "@react-three/fiber"; import { useEffect, useRef } from "react"; -import { Plane, Raycaster, Vector2, Vector3 } from "three"; +import { Vector2, Vector3 } from "three"; interface CameraTarget { x: number; @@ -21,14 +22,37 @@ interface Props { * @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; + /** + * 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 } = props; + const { + target, + clampVerticalRotation, + minPhi = 0.01, + maxPhi = Math.PI - 0.01, + onReset, + } = props; + const { camera, gl } = useThree(); - // const panPlane = useRef(new Plane()); - // const panStartPoint = useRef(new Vector3()); - // const raycaster = useRef(new Raycaster()); + const mouse = useRef(new Vector2()); const targetRef = useRef({ x: target?.x ?? 0, @@ -43,14 +67,15 @@ export default function OrbitCameraControls(props: Props) { // Spherical coords const spherical = useRef({ - radius: 10, - theta: 0, // horizontal - phi: Math.PI / 2, // vertical + radius: props.initialRadius ?? 10, + theta: 0, // horizontal angle + phi: Math.PI / 2, // vertical angle }); useEffect(() => { const canvas = gl.domElement; canvas.addEventListener("contextmenu", e => e.preventDefault()); + const updateCamera = () => { const { radius, theta, phi } = spherical.current; @@ -70,66 +95,50 @@ export default function OrbitCameraControls(props: Props) { ); }; + // Hand the reset function to the caller so they can trigger it + // (e.g. from a button in CameraOverlay) without needing an external ref. + if (onReset) { + onReset(() => { + spherical.current.radius = props.initialRadius ?? 10; + spherical.current.theta = 0; + spherical.current.phi = Math.PI / 2; + targetRef.current = { + x: target?.x ?? 0, + y: target?.y ?? 0, + z: target?.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); - - // Left click = rotate + if (e.button === 0) { isDragging.current = true; - isPanning.current = false; + isPanning.current = false; } - - // Right click = pan + if (e.button === 2) { - isPanning.current = true; + isPanning.current = true; isDragging.current = false; - - // plane facing camera through target - const camDir = new Vector3(); - camera.getWorldDirection(camDir); - - // panPlane.current.setFromNormalAndCoplanarPoint( - // camDir, - // new Vector3( - // targetRef.current.x, - // targetRef.current.y, - // targetRef.current.z - // ) - // ); - - // const rect = canvas.getBoundingClientRect(); - - // raycaster.current.setFromCamera( - // new Vector2( - // ((e.clientX - rect.left) / rect.width) * 2 - 1, - // -((e.clientY - rect.top) / rect.height) * 2 + 1 - // ), - // camera - // ); - - // raycaster.current.ray.intersectPlane( - // panPlane.current, - // panStartPoint.current - // ); } }; - - const handleMouseMove = (e: MouseEvent) => { const deltaX = e.clientX - lastPos.current.x; const deltaY = e.clientY - lastPos.current.y; - - // ROTATE + + // ROTATE — left drag if (isDragging.current) { spherical.current.theta -= deltaX * 0.005; - spherical.current.phi -= deltaY * 0.005; - + spherical.current.phi -= deltaY * 0.005; + if (clampVerticalRotation) { spherical.current.phi = Math.max( minPhi, @@ -137,72 +146,50 @@ export default function OrbitCameraControls(props: Props) { ); } } - - // PAN - // if (isPanning.current) { - // const rect = canvas.getBoundingClientRect(); - - // mouse.current.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; - // mouse.current.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; - - // const currentPoint = new Vector3(); - - // const tempCamera = camera.clone(); - // tempCamera.position.copy(panCameraPosition.current); - // tempCamera.updateMatrixWorld(); - - // raycaster.current.setFromCamera(mouse.current, tempCamera); - - // raycaster.current.ray.intersectPlane(panPlane.current, currentPoint); - - // const delta = new Vector3().subVectors( - // panStartPoint.current, - // currentPoint - // ); - - // targetRef.current.x += delta.x; - // targetRef.current.y += delta.y; - // targetRef.current.z += delta.z; - - // panStartPoint.current.copy(currentPoint); - // } + + // 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 deltaX = e.clientX - lastPos.current.x; - const deltaY = e.clientY - lastPos.current.y; - - // distance-based scaling (important for consistent feel) const distance = spherical.current.radius; - const panSpeed = 0.002 * distance; - - const offset = new Vector3(); - - // get camera basis vectors + + const forward = new Vector3(); + camera.getWorldDirection(forward); + + // Right vector — perpendicular to forward in the horizontal plane const right = new Vector3(); - const up = new Vector3(); - - camera.getWorldDirection(offset); // forward - right.crossVectors(offset, camera.up).normalize(); // right - up.copy(camera.up).normalize(); - - // apply movement + 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); - up.multiplyScalar(deltaY * panSpeed); - - const pan = new Vector3().addVectors(right, up); - + 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; + isPanning.current = false; }; const handleWheel = (e: WheelEvent) => { @@ -211,11 +198,9 @@ export default function OrbitCameraControls(props: Props) { e.preventDefault(); spherical.current.radius += e.deltaY * 0.01; - - // Clamp zoom - spherical.current.radius = Math.max( + spherical.current.radius = Math.max( 3, - Math.min(30, spherical.current.radius) + Math.min(props.maxRadius ?? 30, spherical.current.radius) ); updateCamera(); @@ -223,16 +208,14 @@ export default function OrbitCameraControls(props: Props) { window.addEventListener("mousedown", handleMouseDown); window.addEventListener("mousemove", handleMouseMove); - window.addEventListener("mouseup", handleMouseUp); - - canvas.addEventListener("wheel", handleWheel, { passive: false }); + window.addEventListener("mouseup", handleMouseUp); + canvas.addEventListener("wheel", handleWheel, { passive: false }); return () => { window.removeEventListener("mousedown", handleMouseDown); window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("mouseup", handleMouseUp); - - canvas.removeEventListener("wheel", handleWheel); + window.removeEventListener("mouseup", handleMouseUp); + canvas.removeEventListener("wheel", handleWheel); }; }, [camera, gl, target, clampVerticalRotation, minPhi, maxPhi]); diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index 68b5373..41ef6d6 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -1,5 +1,6 @@ - +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"; @@ -13,28 +14,47 @@ import { pond } from "protobuf-ts/pond"; import GrainCableFill from "../Systems/Inventory/GrainCableFill"; import TempHeatMap from "../Systems/Heatmap/TempHeatMap"; import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; - + interface Props { bin: Bin scale: number fillPercent?: number nodeClick?: (node: NodeData, cable: CableData) => void showGrain?: boolean - showHeatmap?: 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 } - + export default function Bin3dView(props: Props) { - const { bin, scale = 100, fillPercent, nodeClick, showHeatmap, showGrain, showHotspots } = props - + const { bin, scale = 100, fillPercent, nodeClick, showTempHeatmap, showGrain, showHotspots, showResetButton, showMoistureHeatmap, showTemp, showMoisture } = props + const binCenter = useMemo(() => new Vector3(0, 0, 0), []); + + // Compute the initial camera radius so the full bin fits in frame. + // Uses the perspective camera FOV (default 75°) with a padding factor. + // Both the diameter and total height are considered so neither is clipped. + const initialCameraRadius = useMemo(() => { + const totalHeight = (bin.sidewallHeight() + bin.roofHeight() + (bin.hopperHeight() ?? 0)) / scale; + const diameter = bin.diameter() / scale; + const largestDim = Math.max(totalHeight, diameter); + const fovRad = (75 * Math.PI) / 180; + const padding = 1.3; // 30% breathing room + return (largestDim / (2 * Math.tan(fovRad / 2))) * padding; + }, [bin, scale]); const cableData = useMemo(() => BuildCableData(bin), [bin]); 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. // @@ -42,21 +62,24 @@ export default function Bin3dView(props: Props) { // using the same volume math as GrainFillFlat and pass it as flatMaxY. const flatMaxY = useMemo(() => { if (isCableInventory || fillPercent === undefined) return undefined; - + const radius = bin.diameter() / 2; const sidewallH = bin.sidewallHeight(); const hopperH = bin.hopperHeight() ?? 0; const cylVolume = Math.PI * radius * radius * sidewallH; const hopperVol = hopperH > 0 ? (1 / 3) * Math.PI * radius * radius * hopperH : 0; const filled = (cylVolume + hopperVol) * fillPercent; - + const cylinderFillH = hopperH > 0 && filled <= hopperVol ? 0 : Math.max(0, (filled - hopperVol) / (Math.PI * radius * radius)); - + return -sidewallH / 2 + cylinderFillH; }, [isCableInventory, fillPercent, bin]); - + + // Internal ref — wired to OrbitCameraControls and called by CameraOverlay + const resetCameraFn = React.useRef<(() => void) | null>(null); + const grainInventory = () => { if (isCableInventory) { return ( @@ -81,7 +104,7 @@ export default function Bin3dView(props: Props) { ) } } - + return ( @@ -97,10 +120,12 @@ export default function Bin3dView(props: Props) { hopperHeight={bin.hopperHeight()} renderOrder={4} /> - + {showGrain && grainInventory()} - + - + {showHotspots && } - - {showHeatmap && ( + + {showTempHeatmap && ( )} + - + - + { resetCameraFn.current = fn; }} + /> + {/* resetCameraFn.current?.()} + /> */} ) } diff --git a/src/bin/3dView/Systems/Cables/BinCables.tsx b/src/bin/3dView/Systems/Cables/BinCables.tsx index 6adedd1..7480e46 100644 --- a/src/bin/3dView/Systems/Cables/BinCables.tsx +++ b/src/bin/3dView/Systems/Cables/BinCables.tsx @@ -11,11 +11,13 @@ interface Props { bin: Bin binCenter: Vector3, renderOrder?: number, + displayTemp?: boolean, + displayMoisture?: boolean, onNodeClick?: (node: NodeData, cable: CableData) => void } export default function BinCables(props: Props){ - const {bin, binCenter, cableData, nodeData, onNodeClick, renderOrder} = props + const {bin, binCenter, cableData, nodeData, onNodeClick, renderOrder, displayTemp, displayMoisture} = props return ( {cableData.map((cable, i) => { @@ -28,6 +30,8 @@ export default function BinCables(props: Props){ nodes={cableNodes} bin={bin} binCenter={binCenter} + displayTemp={displayTemp} + displayMoisture={displayMoisture} onNodeClick={(node) => { if(onNodeClick){ onNodeClick(node, cable) diff --git a/src/bin/3dView/Systems/Cables/CableNode.tsx b/src/bin/3dView/Systems/Cables/CableNode.tsx index 080422a..99c5824 100644 --- a/src/bin/3dView/Systems/Cables/CableNode.tsx +++ b/src/bin/3dView/Systems/Cables/CableNode.tsx @@ -17,36 +17,39 @@ interface Props { lowerThreshold: number upperThreshold: number, renderOrder?: number, + displayTemp?: boolean, + displayMoisture?: boolean, onNodeClick?: (node: NodeData) => void } export default function CableNode(props: Props) { - const { node, binCenter, lowerThreshold, upperThreshold, onNodeClick, renderOrder } = props + const { node, binCenter, lowerThreshold, upperThreshold, onNodeClick, renderOrder, displayTemp, displayMoisture } = props const { camera } = useThree(); const [{ user }] = useGlobalState(); - const [showLabel, setShowLabel] = useState(false); + // const [showLabel, setShowLabel] = useState(false); const labelGroupRef = React.useRef(null); const ROW_HEIGHT = 35; const PADDING = 20; - useFrame(() => { - const distance = camera.position.distanceTo(binCenter) + //this can be used to control label visibility through the cameras zoom level + // useFrame(() => { + // const distance = camera.position.distanceTo(binCenter) - const SHOW_DISTANCE = 15; - const HIDE_DISTANCE = 16; + // const SHOW_DISTANCE = 15; + // const HIDE_DISTANCE = 16; - if (!showLabel && distance < SHOW_DISTANCE) { - setShowLabel(true); - } else if (showLabel && distance > HIDE_DISTANCE) { - setShowLabel(false); - } + // if (!showLabel && distance < SHOW_DISTANCE) { + // setShowLabel(true); + // } else if (showLabel && distance > HIDE_DISTANCE) { + // setShowLabel(false); + // } - const scale = distance * 0.07; + // const scale = distance * 0.07; - if (labelGroupRef.current) { - labelGroupRef.current.scale.set(scale, scale, 1); - } - }); + // if (labelGroupRef.current) { + // labelGroupRef.current.scale.set(scale, scale, 1); + // } + // }); const tempDisplay = useMemo(() => { if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { @@ -56,36 +59,47 @@ export default function CableNode(props: Props) { }, [node.celcius, user]); const rows = useMemo(() => { - const r: { text: string; color: string }[] = []; - + const anyToggleOn = displayTemp || displayMoisture; + if (node.excluded) { - r.push({ text: "Excluded", color: "red" }); - return r; + if (!anyToggleOn) return []; + return [{ text: "Excluded", color: "red" }]; } - - if (node.topNode) { - r.push({ text: "Top Node", color: "yellow" }); - } - - r.push({ - text: tempDisplay, - color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour() - }); - - if (node.moisture) { + + const r: { text: string; color: string }[] = []; + + // TEMP + if (displayTemp && node.celcius !== 0) { r.push({ - text: `${node.moisture.toFixed(2)}% EMC`, - color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour() - }); - } else if (node.humidity) { - r.push({ - text: `${node.humidity.toFixed(2)}%`, - color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour() + text: tempDisplay, + color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour() }); } - + + // 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() + }); + } else if (node.humidity && node.humidity > 0) { + r.push({ + text: `${node.humidity.toFixed(2)}%`, + color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour() + }); + } + } + + const hasData = r.length > 0; + + // Only show top node if there's actual data + if (node.topNode && hasData) { + r.unshift({ text: "Top Node", color: "yellow" }); + } + return r; - }, [node, tempDisplay]); + }, [node, tempDisplay, displayTemp, displayMoisture]); const geometry = React.useMemo(() => ({ radius: node.radius, @@ -126,6 +140,8 @@ export default function CableNode(props: Props) { onNodeClick?.(node); }; + const showLabel = rows.length > 0; + return ( {(!node.inGrain || !showLabel) && diff --git a/src/bin/3dView/Systems/Cables/GrainCable.tsx b/src/bin/3dView/Systems/Cables/GrainCable.tsx index 938dc63..73add6a 100644 --- a/src/bin/3dView/Systems/Cables/GrainCable.tsx +++ b/src/bin/3dView/Systems/Cables/GrainCable.tsx @@ -12,10 +12,12 @@ interface Props { bin: Bin binCenter: Vector3, renderOrder?: number, + displayTemp?: boolean, + displayMoisture?: boolean onNodeClick?: (node: NodeData) => void } export default function GrainCable(props: Props) { - const {cable, nodes, bin, binCenter, onNodeClick, renderOrder} = props + const {cable, nodes, bin, binCenter, onNodeClick, renderOrder, displayTemp, displayMoisture} = props const calculateHeight = () => { return cable.topPosition.distanceTo(cable.bottomPosition) @@ -42,7 +44,7 @@ export default function GrainCable(props: Props) { {nodes.map((node, i) => ( - + ))} ) diff --git a/src/bin/binSummary/BinSummary.tsx b/src/bin/binSummary/BinSummary.tsx new file mode 100644 index 0000000..88f9b51 --- /dev/null +++ b/src/bin/binSummary/BinSummary.tsx @@ -0,0 +1,76 @@ +import { Box, Card, Grid2 } from "@mui/material"; +import { useMobile } from "hooks"; +import { Bin } from "models"; +import Bin3dVisualizer from "./components/bin3dVisualizer"; + +interface Props { + bin: Bin +} +export default function BinSummary(props: Props){ + const {bin} = props + const isMobile = useMobile() + //need to decide if the vies shoulw both be displayed or toggled between + const bin3dView = () => { + return ( + + ) + } + + const tableView = () => { + return ( + table View + ) + } + + const inventoryOverView = () => { + return ( + inventory overview + ) + } + + const sensorData = () => { + return ( + sensor data + ) + } + + const controllerStatus = () => { + return ( + controller status + ) + } + + + + return ( + + + + + {bin3dView()} + + + + + {tableView()} + + + + + {inventoryOverView()} + + + + + {sensorData()} + + + + + {controllerStatus()} + + + + + ) +} \ No newline at end of file diff --git a/src/bin/binSummary/components/bin3dVisualizer.tsx b/src/bin/binSummary/components/bin3dVisualizer.tsx new file mode 100644 index 0000000..62cf814 --- /dev/null +++ b/src/bin/binSummary/components/bin3dVisualizer.tsx @@ -0,0 +1,105 @@ +import { Box, Checkbox, Grid2, FormControlLabel } from "@mui/material"; +import Bin3dView from "bin/3dView/Scene/Bin3dView"; +import { Bin } from "models"; +import { useState } from "react"; + +interface Props { + bin: Bin +} +export default function bin3dVisualizer(props: Props){ + const {bin} = props + const [showHeatmap, setShowHeatmap] = useState(true) + const [showHotspots, setShowHotspots] = useState(false) + const [showFill, setShowFill] = useState(false) + const [showTemp, setShowTemp] = useState(false) + const [showMoisture, setShowMoisture] = useState(false) + + const getFill = () => { + //calculate the fill percent of the bin when using manual/lidar for inventory + + return 0 + } + + + return ( + + + + { + setShowHeatmap(checked); + }} + /> + } + label={"Toggle Heatmap"} + /> + { + setShowHotspots(checked); + }} + /> + } + label={"Toggle Hotspots"} + /> + { + setShowFill(checked); + }} + /> + } + label={"Toggle Hotspots"} + /> + { + setShowTemp(checked); + }} + /> + } + label={"Toggle Temp"} + /> + { + setShowMoisture(checked); + }} + /> + } + label={"Toggle Moisture"} + /> + + + + + + + + + ) +} \ No newline at end of file diff --git a/src/navigation/Router.tsx b/src/navigation/Router.tsx index 94ce101..066b5be 100644 --- a/src/navigation/Router.tsx +++ b/src/navigation/Router.tsx @@ -20,6 +20,7 @@ const Teams = lazy(() => import("pages/Teams")); const Users = lazy(() => import("pages/Users")); const TeamPage = lazy(() => import("pages/Team")); const BinPage = lazy(() => import("pages/Bin")); +const BinPageV2 = lazy(()=> import("pages/BinV2")); const Bins = lazy(() => import("pages/Bins")); const Mines = lazy(() => import("pages/Mines")); const DeviceComponent = lazy(() => import("pages/DeviceComponent")); @@ -139,10 +140,17 @@ export default function Router() { path="" // "/settings/basic" element={} /> + {user.hasFeature("beta") ? + } + /> + : } /> + } {/* } diff --git a/src/pages/Bin.tsx b/src/pages/Bin.tsx index 7531ab0..f410bd2 100644 --- a/src/pages/Bin.tsx +++ b/src/pages/Bin.tsx @@ -816,53 +816,6 @@ export default function Bin(props: Props) { {overview()} {preferences && binComponents(preferences)} - - - { - setFillPercent(+e.target.value) - }} - /> - { - setShowGrain(checked); - }} - /> - } - label={"Inventory Toggle"} - /> - { - setShowHotspots(checked); - }} - /> - } - label={"Hot Spots"} - /> - { - setShowHeatmap(checked); - }} - /> - } - label={"Heatmap"} - /> - - - {(bin.settings.mode === pond.BinMode.BIN_MODE_DRYING || diff --git a/src/pages/BinV2.tsx b/src/pages/BinV2.tsx new file mode 100644 index 0000000..39691e6 --- /dev/null +++ b/src/pages/BinV2.tsx @@ -0,0 +1,482 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import PageContainer from "./PageContainer"; +import { DevicePreset } from "models/DevicePreset"; +import { Controller } from "models/Controller"; +import { useNavigate, useParams } from "react-router-dom"; +import { useMobile } from "hooks"; +import { Component, Device, Bin as IBin, Interaction } from "models"; +import { useBinAPI, useGlobalState } from "providers"; +import { pond } from "protobuf-ts/pond"; +import { Plenum } from "models/Plenum"; +import { Ambient } from "models/Ambient"; +import { GrainCable } from "models/GrainCable"; +import { Pressure } from "models/Pressure"; +import { CO2 } from "models/CO2"; +import moment from "moment"; +import { quack } from "protobuf-ts/quack"; +import { Box, Drawer, Grid2 as Grid, IconButton, Theme, Tooltip } from "@mui/material"; +import { makeStyles } from "@mui/styles"; +import NotesIcon from "@mui/icons-material/Notes"; +import TasksIcon from "products/Construction/TasksIcon"; +import BindaptIcon from "products/Bindapt/BindaptIcon"; +import FieldsIcon from "products/AgIcons/FieldsIcon"; +import BinActions from "bin/BinActions"; +import BinSummary from "bin/binSummary/BinSummary"; + +const useStyles = makeStyles((theme: Theme) => { + const themeType = theme.palette.mode; + const activeBG = theme.palette.secondary.main; + return ({ + spacer: { + width: "32px", + height: "32px", + padding: "auto", + display: "flex", + justifyContent: "center", + alignItems: "center" + }, + avatar: { + color: themeType === "light" ? theme.palette.common.black : theme.palette.common.white, + backgroundColor: "transparent", + width: theme.spacing(5), + height: theme.spacing(5), + border: "1px solid", + borderColor: theme.palette.divider + }, + }) +}) + + +export default function BinV2(){ + const warningThreshold = 6; + const isMobile = useMobile(); + const binID = useParams<{ binID: string }>()?.binID ?? ""; + const binAPI = useBinAPI(); + const classes = useStyles() + const navigate = useNavigate() + const [{ user, as, showErrors }] = useGlobalState(); + const [binLoading, setBinLoading] = useState(false); + const loadRef = useRef(false); + const [bin, setBin] = useState(IBin.create()); + const [devices, setDevices] = useState([]); + const [components, setComponents] = useState>(new Map()); + const [interactions, setInteractions] = useState([]); + const [permissions, setPermissions] = useState([]); + const [preferences, setPreferences] = useState>(); + const [plenums, setPlenums] = useState([]); + const [ambients, setAmbients] = useState([]); + const [grainCables, setGrainCables] = useState([]); + const [pressures, setPressures] = useState([]); + const [headspaceCO2, setHeadspaceCO2] = useState([]); + const [heaters, setHeaters] = useState([]); + const [fans, setFans] = useState([]); + const [compositionNameMap, setCompositionNameMap] = useState>(new Map()); + const [anchorEl, setAnchorEl] = useState(null); + + + const [componentDevices, setComponentDevices] = useState>( + new Map() + ); + const [interactionDevices, setInteractionDevices] = useState>( + new Map() + ); + const [binPresets, setBinPresets] = useState([]); + const [missedReadings, setMissedReadings] = useState(0); + + const [currentSection, setCurrentSection] = useState<"binSum">("binSum") + + //Drawer states + const [noteDrawerOpen, setNoteDrawerOpen] = useState(false) + const [taskDrawerOpen, setTaskDrawerOpen] = useState(false) + + //DATA LOAD/UPDATE FUNCTIONS + + const load = useCallback(() => { + if (loadRef.current || user.id() === "") return; + setBinLoading(true); + loadRef.current = true; + //add the presets to the bin page data load + binAPI + .getBinPageData(binID, user.id(), showErrors, as) + .then(resp => { + if (resp.data.grainCompositionNames) { + let tempMap: Map = new Map(); + Object.keys(resp.data.grainCompositionNames).forEach(key => { + tempMap.set(key, resp.data.grainCompositionNames[key]); + }); + tempMap.set("correction", "Unknown Source"); + setCompositionNameMap(tempMap); + } + + let devs: Device[] = []; + let p = new Map(); + Object.keys(resp.data.preferences).forEach(k => { + let prefKey = k.split(":")[1] ? k.split(":")[1] : k; + p.set(prefKey, pond.BinComponentPreferences.fromObject(resp.data.preferences[k])); + }); + setPreferences(p); + let r: pond.GetBinPageDataResponse = pond.GetBinPageDataResponse.fromObject(resp.data); + setBin(IBin.any(resp.data.bin)); + let newComponentDevices = new Map(); + let attachedDevIds: number[] = []; + if (resp.data.componentDevices) + Object.keys(resp.data.componentDevices).forEach(k => { + newComponentDevices.set(k, resp.data.componentDevices[k]); + //make a list of all of the ids for devices that components are currently attached + if (!attachedDevIds.includes(resp.data.componentDevices[k])) { + attachedDevIds.push(resp.data.componentDevices[k]); + } + }); + setComponentDevices(newComponentDevices); + + let newInteractionDevices = new Map(); + if (r.interactionDevices) + Object.keys(r.interactionDevices).forEach(k => { + newInteractionDevices.set(k, r.interactionDevices[k]); + }); + setInteractionDevices(newInteractionDevices); + + if (resp.data.interactions) { + setInteractions(resp.data.interactions.map((i: pond.Interaction) => Interaction.any(i))); + } else { + setInteractions([]); + } + //go through the devices and if the device has an attached component include it in the device list + if (resp.data.devices && resp.data.devices[0]) { + resp.data.devices.forEach((dev: any) => { + let device = Device.create(dev); + if (attachedDevIds.includes(device.id())) { + devs.push(device); + } + }); + setDevices(devs); + } + if (resp.data.components) { + let compMap: Map = new Map(); + resp.data.components.forEach((comp: pond.Component) => { + if (comp && comp.settings) { + let c = Component.any(comp); + compMap.set(comp.settings.key, c); + } + }); + setComponents(compMap); + } + if (resp.data.presets) { + setBinPresets( + resp.data.presets.map((preset: pond.DevicePreset) => DevicePreset.create(preset)) + ); + } + setPermissions(r.permissions ? r.permissions : []); + setBinLoading(false); + loadRef.current = false; + }) + .finally(() => { + setBinLoading(false); + loadRef.current = false; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [binID, user.id(), binAPI, showErrors, as]); + + useEffect(() => { + load(); + }, [load]); + + const setBinComponents = useCallback(() => { + if (!preferences) return; + var unassigned: Component[] = []; + var plenums: Plenum[] = []; + var ambients: Ambient[] = []; + var grainCables: GrainCable[] = []; + var fans: Controller[] = []; + var heaters: Controller[] = []; + var pressures: Pressure[] = []; + var headspaceCo2: CO2[] = []; + var mostMissed: number = 0; + let now = moment(); + + components.forEach(comp => { + let pref = preferences.get(comp.key()); + if (pref) { + if (pref.type) { + if (pref.type === pond.BinComponent.BIN_COMPONENT_PLENUM) + plenums.push(Plenum.create(comp)); + if (pref.type === pond.BinComponent.BIN_COMPONENT_AMBIENT) + ambients.push(Ambient.create(comp)); + if (pref.type === pond.BinComponent.BIN_COMPONENT_GRAIN_CABLE) { + //check cable for missed measurements + let cable = GrainCable.create(comp); + let lastRead = moment(cable.lastReading); + let elapsedMS = now.diff(lastRead); + if (elapsedMS > cable.settings.measurementPeriodMs) { + let missedReadings = Math.floor(elapsedMS / cable.settings.measurementPeriodMs); + if (missedReadings > mostMissed) { + mostMissed = missedReadings; + } + } + grainCables.push(cable); + } + if (pref.type === pond.BinComponent.BIN_COMPONENT_HEATER) + heaters.push(Controller.create(comp)); + if (pref.type === pond.BinComponent.BIN_COMPONENT_FAN) fans.push(Controller.create(comp)); + if (pref.type === pond.BinComponent.BIN_COMPONENT_PRESSURE) + pressures.push(Pressure.create(comp)); + if (pref.type === pond.BinComponent.BIN_COMPONENT_HEADSPACE) { + if (comp.type() === quack.ComponentType.COMPONENT_TYPE_CO2) { + //check if there are missed readings from the co2 and compare them to what we already had + let coComp = CO2.create(comp) + let lastRead = moment(coComp.lastReading); + let elapsedMS = now.diff(lastRead); + if (elapsedMS > coComp.settings.measurementPeriodMs) { + let missedReadings = Math.floor(elapsedMS / coComp.settings.measurementPeriodMs); + if (missedReadings > mostMissed) { + mostMissed = missedReadings; + } + } + headspaceCo2.push(coComp); + } + } + } else { + unassigned.push(comp); + } + } + }); + setMissedReadings(mostMissed); + setPlenums(plenums); + setGrainCables(grainCables); + setPressures(pressures); + setAmbients(ambients); + setHeaters(heaters); + setFans(fans); + setHeadspaceCO2(headspaceCo2); + }, [components, preferences]); + + useEffect(() => { + setBinComponents(); + }, [setBinComponents]); + + const updateStatus = (componentKeys: string[], removed?: boolean) => { + componentKeys.forEach(compKey => { + let comp = components.get(compKey); + if (comp) { + //determine what part of the status to update based on the component + //for lidar + if (comp.type() === quack.ComponentType.COMPONENT_TYPE_LIDAR) { + //determine how to update the status based on if added or removed + if (removed) { + bin.status.distance = 0; + } else { + if ( + comp.lastMeasurement[0] && + comp.lastMeasurement[0].values[0] && + comp.lastMeasurement[0].values[0].values[0] + ) { + bin.status.distance = comp.lastMeasurement[0].values[0].values[0]; + } + } + } + } + }); + //update the bins status with the new values based on the components changed + binAPI.updateBinStatus(bin.key(), bin.status, as); + }; + + //NAVIGATION FUNCTIONS + const goToDevice = (dev: Device) => { + navigate(window.location.pathname + "/devices/" + dev.id(), { replace: true }); + }; + + const goToYard = (bin: IBin) => { + navigate("/visualFarm", { state: { + long: bin.getLocation()?.longitude, + lat: bin.getLocation()?.latitude + } + }); + }; + + + /** + * UI STUFF STARTS HERE + */ + + /** + * Header - consists of the actions and things that are always visible like + * opening the note/history and tasks drawers, navigating to the device and map pages, opening binsensors and settings etc. + * also has the bin name/model and activity and the dropdown for changing sections + */ + const pageHeader = () => { + return ( + + + + + setNoteDrawerOpen(true) + } + size="small" + style={{ + marginRight: "0.5rem" + }} + className={classes.avatar} + component="span"> +
+ +
+
+ + setTaskDrawerOpen(true) + } + size="small" + style={{ + marginRight: "0.5rem" + }} + className={classes.avatar} + component="span"> +
+ +
+
+ {devices.length > 1 ? ( + + ) => + setAnchorEl(event.currentTarget) + } + size="small" + style={{ + marginRight: "0.5rem" + }} + className={classes.avatar} + component="span"> + + + + ) : ( + devices.map(dev => ( + + goToDevice(dev)} + size="small" + style={{ + marginRight: "0.5rem" + }} + className={classes.avatar} + component="span"> +
+ +
+
+
+ )) + )} + {bin.binMapped() && ( + + { + goToYard(bin); + }} + size="small" + style={{ + marginRight: "0.5rem" + }} + className={classes.avatar} + component="span"> + + + + )} +
+ + { + load(); + }} + userID={user.id()} + components={components} + setComponents={setComponents} + componentDevices={componentDevices} + setComponentDevices={setComponentDevices} + updateBinStatus={updateStatus} + /> + +
+
+ ) + } + + //PAGE SECTIONS + + /** + * Contains the main summary of your bin as it currently is + * has the 3D bin model, a switch to go to the table view + * the bin conditions if it is in a mode other than storage (the do nothing mode) + * a box underneath to display sensor data, for the plenums and such + * as well as the controls for attached controllers + */ + const binSummarySection = () => { + return ( + + ) + } + + /** + * this sections is for showing attached bin components and assigning them to bin positions (see old pages bin components) + * as well as seeing the graph data for attached sensors + */ + const sensorsSection = () => {} + + //Inventory + /** + * this section is for seeing information relating to the bins inventory for its fill level over time and transactions related to inventory of the bin + */ + const InventorySection = () => {} + + //Analysis + /** + * this section is for the analytical charts (drying/hydrating, Moisture trending, Grain Water Content) + */ + const analysisSection = () => {} + + //Interactions + /** + * this is the section for alerts and controls on the bin, if presets get re-implemented they can go here too + */ + const interactionsSection = () => {} + + + //DRAWERS + const noteDrawer = () => { + return ( + + + + ) + } + + const taskDrawer = () => { + return ( + + + + ) + } + + return ( + + {pageHeader()} + {currentSection === "binSum" && binSummarySection()} + {/* render drawers */} + {noteDrawer()} + {taskDrawer()} + + ) +} \ No newline at end of file From 325d3ea7a0a468114e47b524ba4ff1172aea5c3c Mon Sep 17 00:00:00 2001 From: csawatzky Date: Mon, 4 May 2026 09:25:43 -0600 Subject: [PATCH 062/146] made binTableView component, and am starting to average node levels --- src/bin/binSummary/BinSummary.tsx | 3 +- .../binSummary/components/binTableView.tsx | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 src/bin/binSummary/components/binTableView.tsx diff --git a/src/bin/binSummary/BinSummary.tsx b/src/bin/binSummary/BinSummary.tsx index 88f9b51..a4777e7 100644 --- a/src/bin/binSummary/BinSummary.tsx +++ b/src/bin/binSummary/BinSummary.tsx @@ -2,6 +2,7 @@ import { Box, Card, Grid2 } from "@mui/material"; import { useMobile } from "hooks"; import { Bin } from "models"; import Bin3dVisualizer from "./components/bin3dVisualizer"; +import BinTableView from "./components/binTableView"; interface Props { bin: Bin @@ -18,7 +19,7 @@ export default function BinSummary(props: Props){ const tableView = () => { return ( - table View + ) } diff --git a/src/bin/binSummary/components/binTableView.tsx b/src/bin/binSummary/components/binTableView.tsx new file mode 100644 index 0000000..42d4fb9 --- /dev/null +++ b/src/bin/binSummary/components/binTableView.tsx @@ -0,0 +1,37 @@ +import { Box } from "@mui/material"; +import { Bin } from "models"; +import { useMemo } from "react"; + +interface Props { + bin: Bin +} + +interface LevelAvg { + temp: number + moisture: number + inGrain: boolean + +} + + +export default function BinTableView(props: Props) { + const {bin} = props + + const cableLevelAverages = useMemo(() => { + const cables = bin.status.grainCables + let levels: LevelAvg[] = [] + console.log(cables) + //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 => { + + }) + return levels + },[bin]) + + return ( + Table View + ) +} \ No newline at end of file From ac57b1b0f4b77cd91dde47bf2fa0e1e83db05702 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 5 May 2026 09:17:22 -0600 Subject: [PATCH 063/146] removing dead link in bin visualizer when no plenum is attached to a bin --- src/bin/BinVisualizerV2.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bin/BinVisualizerV2.tsx b/src/bin/BinVisualizerV2.tsx index f6f9c1d..53c3d83 100644 --- a/src/bin/BinVisualizerV2.tsx +++ b/src/bin/BinVisualizerV2.tsx @@ -1159,7 +1159,7 @@ export default function BinVisualizer(props: Props) { - View Smart Bin Devices + No Plenums found )} From 71ab8ab5cb1bf48bbd1242f0fd5b2b736745e766 Mon Sep 17 00:00:00 2001 From: Carter Date: Tue, 5 May 2026 11:40:28 -0600 Subject: [PATCH 064/146] switched to master protobuf --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 90ac71b..7f5044c 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#master", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", From 619f9b7bf45b8f6d7fa4bcb7449e8e175653e5ce Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 6 May 2026 13:08:04 -0600 Subject: [PATCH 065/146] some tweaks to the 3d bin and nodes, finished up the table view --- src/bin/3dView/Data/BuildNodeData.ts | 2 +- src/bin/3dView/Scene/Bin3dView.tsx | 4 +- src/bin/3dView/Systems/Cables/BinCables.tsx | 11 +- src/bin/3dView/Systems/Cables/CableNode.tsx | 170 +++++++++--------- src/bin/3dView/Systems/Cables/GrainCable.tsx | 13 +- src/bin/binSummary/BinSummary.tsx | 4 +- .../binSummary/components/bin3dVisualizer.tsx | 79 +++++--- .../binSummary/components/binTableView.tsx | 150 ++++++++++++++-- src/models/Bin.ts | 4 + 9 files changed, 291 insertions(+), 146 deletions(-) diff --git a/src/bin/3dView/Data/BuildNodeData.ts b/src/bin/3dView/Data/BuildNodeData.ts index 4103ce5..fd3f5dd 100644 --- a/src/bin/3dView/Data/BuildNodeData.ts +++ b/src/bin/3dView/Data/BuildNodeData.ts @@ -21,7 +21,7 @@ export interface NodeData { } export function BuildNodeData(cables: CableData[]): NodeData[] { - const nodeRadius = 20 + const nodeRadius = 15 let nodeData: NodeData[] = [] cables.forEach((cable, cableIndex) => { diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index 41ef6d6..cc2ae26 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -14,6 +14,7 @@ import { pond } from "protobuf-ts/pond"; import GrainCableFill from "../Systems/Inventory/GrainCableFill"; import TempHeatMap from "../Systems/Heatmap/TempHeatMap"; import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; +import { Box } from "@mui/material"; interface Props { bin: Bin @@ -35,8 +36,6 @@ interface Props { export default function Bin3dView(props: Props) { const { bin, scale = 100, fillPercent, nodeClick, showTempHeatmap, showGrain, showHotspots, showResetButton, showMoistureHeatmap, showTemp, showMoisture } = props - const binCenter = useMemo(() => new Vector3(0, 0, 0), []); - // Compute the initial camera radius so the full bin fits in frame. // Uses the perspective camera FOV (default 75°) with a padding factor. // Both the diameter and total height are considered so neither is clipped. @@ -129,7 +128,6 @@ export default function Bin3dView(props: Props) { cableData={cableData} nodeData={nodeData} bin={bin} - binCenter={binCenter} onNodeClick={(node, cable) => { if (nodeClick) nodeClick(node, cable) }} diff --git a/src/bin/3dView/Systems/Cables/BinCables.tsx b/src/bin/3dView/Systems/Cables/BinCables.tsx index 7480e46..1901178 100644 --- a/src/bin/3dView/Systems/Cables/BinCables.tsx +++ b/src/bin/3dView/Systems/Cables/BinCables.tsx @@ -1,15 +1,13 @@ import GrainCable from "./GrainCable"; -import { Vector3 } from "three"; import { Bin } from "models"; import React from "react"; import { CableData } from "../../Data/BuildCableData"; import { NodeData } from "../../Data/BuildNodeData"; interface Props { - cableData: CableData[] - nodeData: NodeData[] - bin: Bin - binCenter: Vector3, + cableData: CableData[], + nodeData: NodeData[], + bin: Bin, renderOrder?: number, displayTemp?: boolean, displayMoisture?: boolean, @@ -17,7 +15,7 @@ interface Props { } export default function BinCables(props: Props){ - const {bin, binCenter, cableData, nodeData, onNodeClick, renderOrder, displayTemp, displayMoisture} = props + const {bin, cableData, nodeData, onNodeClick, renderOrder, displayTemp, displayMoisture} = props return ( {cableData.map((cable, i) => { @@ -29,7 +27,6 @@ export default function BinCables(props: Props){ cable={cable} nodes={cableNodes} bin={bin} - binCenter={binCenter} displayTemp={displayTemp} displayMoisture={displayMoisture} onNodeClick={(node) => { diff --git a/src/bin/3dView/Systems/Cables/CableNode.tsx b/src/bin/3dView/Systems/Cables/CableNode.tsx index 99c5824..ecaaf18 100644 --- a/src/bin/3dView/Systems/Cables/CableNode.tsx +++ b/src/bin/3dView/Systems/Cables/CableNode.tsx @@ -1,21 +1,18 @@ import Sphere, { SphereGeometry } from "3dModels/Shapes/3D/Sphere" import { useFrame, useThree } from "@react-three/fiber" -import { Billboard, Text } from "@react-three/drei" -import React, { useMemo, useState } from "react" +import { Text } from "@react-three/drei" +import React, { useMemo } from "react" import { Euler, Group, Vector3 } from "three" -import { describeMeasurement } from "pbHelpers/MeasurementDescriber" -import { quack } from "protobuf-ts/quack" +import { green, grey, orange, red } from "@mui/material/colors"; import { useGlobalState } from "providers" import { pond } from "protobuf-ts/pond" import Ring, { RingGeometry } from "3dModels/Shapes/3D/Ring" import { NodeData } from "../../Data/BuildNodeData" -import { TempToColour } from "bin/3dView/utils/tempToColour" interface Props { node: NodeData - binCenter: Vector3 - lowerThreshold: number upperThreshold: number, + targetMoisture: number, renderOrder?: number, displayTemp?: boolean, displayMoisture?: boolean, @@ -23,33 +20,31 @@ interface Props { } export default function CableNode(props: Props) { - const { node, binCenter, lowerThreshold, upperThreshold, onNodeClick, renderOrder, displayTemp, displayMoisture } = props + const { node, upperThreshold, targetMoisture, onNodeClick, renderOrder, displayTemp, displayMoisture } = props const { camera } = useThree(); const [{ user }] = useGlobalState(); // const [showLabel, setShowLabel] = useState(false); - const labelGroupRef = React.useRef(null); - const ROW_HEIGHT = 35; - const PADDING = 20; + const ROW_HEIGHT = 45; + const PADDING = 15; + const LABEL_WIDTH = 220; + const flagRef = React.useRef(null); - //this can be used to control label visibility through the cameras zoom level - // useFrame(() => { - // const distance = camera.position.distanceTo(binCenter) - - // const SHOW_DISTANCE = 15; - // const HIDE_DISTANCE = 16; - - // if (!showLabel && distance < SHOW_DISTANCE) { - // setShowLabel(true); - // } else if (showLabel && distance > HIDE_DISTANCE) { - // setShowLabel(false); - // } - - // const scale = distance * 0.07; - - // if (labelGroupRef.current) { - // labelGroupRef.current.scale.set(scale, scale, 1); - // } - // }); + 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) { @@ -63,7 +58,7 @@ export default function CableNode(props: Props) { if (node.excluded) { if (!anyToggleOn) return []; - return [{ text: "Excluded", color: "red" }]; + return [{ text: "Excluded", color: grey[700] }]; } const r: { text: string; color: string }[] = []; @@ -72,7 +67,9 @@ export default function CableNode(props: Props) { if (displayTemp && node.celcius !== 0) { r.push({ text: tempDisplay, - color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour() + // color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour() + //will be green when below threshold, orange when it goeas above but by less than 5 degrees, and red if more than 5 over + color: node.celcius > upperThreshold ? node.celcius > upperThreshold + 5 ? red[700] : orange[800]: green[800] }); } @@ -81,21 +78,25 @@ export default function CableNode(props: Props) { if (node.moisture && node.moisture > 0) { r.push({ text: `${node.moisture.toFixed(2)}% EMC`, - color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour() + // color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour() + color: node.moisture > targetMoisture ? node.moisture > targetMoisture + 0.5 ? red[700] : orange[800]: green[800] }); - } else if (node.humidity && node.humidity > 0) { - r.push({ - text: `${node.humidity.toFixed(2)}%`, - color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour() - }); - } + } + //apparently we dont want to display humidity at all + // else if (node.humidity && node.humidity > 0) { + // r.push({ + // text: `${node.humidity.toFixed(2)}%`, + // // color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour() + // color: "black" + // }); + // } } const hasData = r.length > 0; // Only show top node if there's actual data if (node.topNode && hasData) { - r.unshift({ text: "Top Node", color: "yellow" }); + r.unshift({ text: "Top Node", color: grey[700] }); } return r; @@ -117,12 +118,8 @@ export default function CableNode(props: Props) { const topNodeRotation = React.useMemo(() => new Euler(-Math.PI / 2, 0, 0), []); - const labelPosition = node.position.clone(); const labelHeight = rows.length * ROW_HEIGHT + PADDING; - const dir = camera.position.clone().sub(node.position).normalize(); - labelPosition.add(dir.multiplyScalar(1)); - const getRowY = (index: number) => { const totalHeight = rows.length * ROW_HEIGHT; return totalHeight / 2 - (index * ROW_HEIGHT) - ROW_HEIGHT / 2; @@ -130,9 +127,7 @@ export default function CableNode(props: Props) { const nodeColour = () => { if (node.excluded) return "grey"; - if (!node.inGrain) return "white"; - const c = TempToColour(node, lowerThreshold, upperThreshold, "node"); - return c !== null ? c.getStyle() : "white"; + return "white"; }; const handleClick = (e: any) => { @@ -144,49 +139,46 @@ export default function CableNode(props: Props) { return ( - {(!node.inGrain || !showLabel) && - - - {node.topNode && ( - - )} - - } + + + {node.topNode && ( + + )} + {node.inGrain && showLabel && - - - - - - - {rows.map((row, i) => ( - - {row.text} - - ))} - - - } + + + + + + {rows.map((row, i) => ( + + {row.text} + + ))} + +} ) } \ No newline at end of file diff --git a/src/bin/3dView/Systems/Cables/GrainCable.tsx b/src/bin/3dView/Systems/Cables/GrainCable.tsx index 73add6a..7c3aa3b 100644 --- a/src/bin/3dView/Systems/Cables/GrainCable.tsx +++ b/src/bin/3dView/Systems/Cables/GrainCable.tsx @@ -7,17 +7,16 @@ import { CableData } from "../../Data/BuildCableData" import { NodeData } from "../../Data/BuildNodeData" interface Props { - cable: CableData - nodes: NodeData[] - bin: Bin - binCenter: Vector3, + cable: CableData, + nodes: NodeData[], + bin: Bin, renderOrder?: number, displayTemp?: boolean, - displayMoisture?: boolean + displayMoisture?: boolean, onNodeClick?: (node: NodeData) => void } export default function GrainCable(props: Props) { - const {cable, nodes, bin, binCenter, onNodeClick, renderOrder, displayTemp, displayMoisture} = props + const {cable, nodes, bin, onNodeClick, renderOrder, displayTemp, displayMoisture} = props const calculateHeight = () => { return cable.topPosition.distanceTo(cable.bottomPosition) @@ -44,7 +43,7 @@ export default function GrainCable(props: Props) { {nodes.map((node, i) => ( - + ))} ) diff --git a/src/bin/binSummary/BinSummary.tsx b/src/bin/binSummary/BinSummary.tsx index a4777e7..737f125 100644 --- a/src/bin/binSummary/BinSummary.tsx +++ b/src/bin/binSummary/BinSummary.tsx @@ -47,12 +47,12 @@ export default function BinSummary(props: Props){ - + {bin3dView()} - + {tableView()} diff --git a/src/bin/binSummary/components/bin3dVisualizer.tsx b/src/bin/binSummary/components/bin3dVisualizer.tsx index 62cf814..5da0a3e 100644 --- a/src/bin/binSummary/components/bin3dVisualizer.tsx +++ b/src/bin/binSummary/components/bin3dVisualizer.tsx @@ -1,6 +1,7 @@ -import { Box, Checkbox, Grid2, FormControlLabel } from "@mui/material"; +import { Box, Checkbox, FormControlLabel, List, ListItem } from "@mui/material"; import Bin3dView from "bin/3dView/Scene/Bin3dView"; import { Bin } from "models"; +import React from "react"; import { useState } from "react"; interface Props { @@ -19,13 +20,13 @@ export default function bin3dVisualizer(props: Props){ return 0 } - - return ( - - - - { + return ( + + + + - + + - + + - + + - + + + - - - - - - - + + + + + + + + ) + } + + const binViewer = () => { + return ( + + ) + } + + return ( + + + {binViewer()} + + {controlsOverlay()} + + ) } \ No newline at end of file diff --git a/src/bin/binSummary/components/binTableView.tsx b/src/bin/binSummary/components/binTableView.tsx index 42d4fb9..daa76ff 100644 --- a/src/bin/binSummary/components/binTableView.tsx +++ b/src/bin/binSummary/components/binTableView.tsx @@ -1,37 +1,165 @@ -import { Box } from "@mui/material"; +import { Box, Table, TableBody, TableCell, TableHead, TableRow } from "@mui/material"; +import { green, grey, orange, red } from "@mui/material/colors"; import { Bin } from "models"; +import { pond } from "protobuf-ts/pond"; +import { useGlobalState } from "providers"; +import React from "react"; import { useMemo } from "react"; +import { avg, celsiusToFahrenheit } from "utils"; interface Props { bin: Bin } -interface LevelAvg { - temp: number - moisture: number - inGrain: boolean - +interface BinLevel { + grainTemps: number[] + airTemps: number[] + grainMoistures: number[] + airMoistures: number[] } export default function BinTableView(props: Props) { const {bin} = props + const [{user}] = useGlobalState() - const cableLevelAverages = useMemo(() => { + const binLevels = useMemo(() => { const cables = bin.status.grainCables - let levels: LevelAvg[] = [] - console.log(cables) + let levels: BinLevel[] = [] //note for future self, the first item in the measurement arrays is the lowest node on the cable //the zones we are creating here will match with the node number because most cables will either have the same number of nodes or only have a difference of 1 or 2 //example: cable 1 has 5 nodes cable 2 has 7 nodes, the first five nodes of the cables will match up and average together for each zone //and then zones 6 and 7 will be made using only cable 2 because cable 1 doesn't have them cables.forEach(cable => { - + cable.celcius.forEach((c, index) => { + //remember index will be 0 based but the top node starts at 1 for the lowest cable + let inGrain = index < cable.topNode + if (levels[index]){ + if(cable.excludedNodes.includes(index+1)) return + if(inGrain){ + levels[index].grainTemps.push(c) + //need to check that the cable not only has the moisture mutation on it, but also that it reads humidity + //cables that dont read humidity return an array of 0 values so if the average of all of the values is 0 then it is a temp only cable + if(cable.moisture.length > 0 && avg(cable.moisture) > 0){ + //based on all the other checks and the fact that the indexes between the array should match this should never be 0 but put a guard here just in case + //also note that we are only doing moisture and not humidity, it seems like users dont care about humidity + levels[index].grainMoistures.push(cable.moisture[index] ?? 0) + } + }else{ + levels[index].airTemps.push(c) + //need to check that the cable not only has the moisture mutation on it, but also that it reads humidity + //cables that dont read humidity return an array of 0 values so if the average of all of the values is 0 then it is a temp only cable + if(cable.moisture.length > 0 && avg(cable.moisture) > 0){ + //based on all the other checks and the fact that the indexes between the array should match this should never be 0 but put a guard here just in case + levels[index].airMoistures.push(cable.moisture[index] ?? 0) + } + } + }else{ + let newlevel: BinLevel = { + grainTemps: [], + grainMoistures: [], + airTemps: [], + airMoistures: [] + } + if(!cable.excludedNodes.includes(index)){ + if(inGrain){ + newlevel.grainTemps.push(c) + if(cable.moisture.length > 0 && avg(cable.moisture) > 0){ + newlevel.grainMoistures.push(cable.moisture[index] ?? 0) + } + }else{ + newlevel.airTemps.push(c) + if(cable.moisture.length > 0 && avg(cable.moisture) > 0){ + newlevel.airMoistures.push(cable.moisture[index] ?? 0) + } + } + } + levels.push(newlevel) + } + + }) }) + //flip the levels so that the highest nodes are on the top + levels.reverse() return levels },[bin]) + const levelDisplay = (level: BinLevel) => { + let tempDisplay = "--" + let moistureDisplay = "--" + let lvlTemp + let lvlMoisture + let tempColour: string = grey[600] + let moistureColour: string = grey[600] + + //determine the temp + if(level.grainTemps.length > 0){ + lvlTemp = avg(level.grainTemps) + //if the average temp is 5 degrees above the bins threshold + if(lvlTemp > (bin.upperTempThreshold() + 5)){ + tempColour = red[700] + }else if(lvlTemp > bin.upperTempThreshold()){ + //if the average temp is greater than 5 degrees over + tempColour = orange[600] + }else { + tempColour = green[300] + } + }else if (level.airTemps.length > 0){ + lvlTemp = avg(level.airTemps) + } + if (lvlTemp){ + if(user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT){ + lvlTemp = celsiusToFahrenheit(lvlTemp) + } + tempDisplay = lvlTemp.toFixed(2) + (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? " F" : " C") + } + + //determine the moisture to show + if(level.grainMoistures.length > 0){ + lvlMoisture = avg(level.grainMoistures) + if(lvlMoisture > (bin.targetMoisture() + 1.5)){ + moistureColour = red[700] + }else if(lvlMoisture > bin.targetMoisture()){ + //if the average temp is greater than 5 degrees over + moistureColour = orange[600] + }else { + moistureColour = green[300] + } + }else if (level.airMoistures.length > 0){ + lvlMoisture = avg(level.airMoistures) + } + if(lvlMoisture){ + moistureDisplay = lvlMoisture.toFixed(2) + "%" + } + return ( + + {tempDisplay} + {moistureDisplay} + + ) + } + + + return ( - Table View + +
+ + + Level + Temperature + Moisture + + + + {binLevels.map((level, i) => ( + + {binLevels.length - i} + {levelDisplay(level)} + + ))} + +
+ ) } \ No newline at end of file diff --git a/src/models/Bin.ts b/src/models/Bin.ts index 475958c..6aa2389 100644 --- a/src/models/Bin.ts +++ b/src/models/Bin.ts @@ -292,4 +292,8 @@ export class Bin { public lowerTempThreshold(): number { return this.settings.lowTemp } + + public targetMoisture(): number { + return this.settings.inventory?.targetMoisture ?? 0 + } } From ae9abaf9fda034c8193c53797be84900a37b16e7 Mon Sep 17 00:00:00 2001 From: Carter Date: Wed, 6 May 2026 18:18:57 -0600 Subject: [PATCH 066/146] local login now functional --- src/app/App.tsx | 30 +++++-- src/app/LocalAuthPlaceholder.tsx | 130 +++++++++++++++++++++++------ src/app/UserWrapper.tsx | 11 ++- src/navigation/BottomNavigator.tsx | 4 +- src/navigation/Router.tsx | 12 +-- src/navigation/SideNavigator.tsx | 4 +- src/pages/Logout.tsx | 4 +- src/providers/http.tsx | 47 ++++++----- src/user/UserMenu.tsx | 4 +- 9 files changed, 174 insertions(+), 72 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index 09956c2..3616784 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -9,9 +9,12 @@ 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(undefined) + const [token, setToken] = useState(() => { + return localStorage.getItem('local_auth_token') || undefined + }) const whiteLabel = getWhitelabel() const manifestPath = "/" + whiteLabel.name.replace(/\s/g, "") + "/manifest.json" @@ -59,9 +62,22 @@ function App() { if (!shouldMountAuth0Provider()) { const placeholderReason = isAuth0Configured() && !isAuth0SpaOriginAllowed() ? 'insecure_origin' : 'unconfigured' + + if (!token) { + return ( + + + + ) + } + return ( - + + + + + ) } @@ -81,10 +97,12 @@ function App() { > {/* */} - { token ? - - - + { token ? + + + + + : void } -/** - * Shown when Auth0 is not used (missing config and/or non–secure context). - * Replace with backend-driven login once local auth is implemented. - */ export default function LocalAuthPlaceholder(props: Props) { - const { reason = 'unconfigured' } = 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 (Auth0 requires HTTPS, localhost, or 127.0.0.1). Use local account sign-in here instead once it is connected.' - : 'Cloud sign-in (Auth0) is not configured for this deployment. Local account sign-in will be available here.' + ? '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 ( <> @@ -35,25 +85,55 @@ export default function LocalAuthPlaceholder(props: Props) { }} > - - - {productName} - - - {explanation} - - - - - - Buttons are placeholders until the backend login flow is wired up. - - + diff --git a/src/app/UserWrapper.tsx b/src/app/UserWrapper.tsx index f837b70..6b0fd92 100644 --- a/src/app/UserWrapper.tsx +++ b/src/app/UserWrapper.tsx @@ -1,7 +1,6 @@ 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' @@ -63,13 +62,19 @@ export default function UserWrapper(props: Props) { const [loading, setLoading] = useState(false) const userAPI = useUserAPI(); const classes = useStyles(); - const useAuth = useAuth0(); const hasFetched = useRef(false); const [global, setGlobal] = useState(undefined) const snackbar = useSnackbar() const isMobile = useMobile() - const user_id = or(useAuth.user?.sub, "") + 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; diff --git a/src/navigation/BottomNavigator.tsx b/src/navigation/BottomNavigator.tsx index 6395bd4..0cd0366 100644 --- a/src/navigation/BottomNavigator.tsx +++ b/src/navigation/BottomNavigator.tsx @@ -20,7 +20,7 @@ import OmniAirDeviceIcon from "products/AviationIcons/OmniAirDeviceIcon"; import AirportMapIcon from "products/AviationIcons/AirportMapIcon"; import PlaneIcon from "products/AviationIcons/PlaneIcon"; import JobsiteIcon from "products/Construction/JobSiteIcon"; -import { useAuth0 } from "@auth0/auth0-react"; +import { useAuthContext } from "providers/authContext"; interface Props { sideIsOpen: boolean; @@ -33,7 +33,7 @@ export default function BottomNavigator(props: Props) { const navigate = useNavigate(); const location = useLocation(); const prevLocation = usePrevious(location); - const { isAuthenticated } = useAuth0(); + const { isAuthenticated } = useAuthContext(); const [{ user }] = useGlobalState(); const [route, setRoute] = useState(sideIsOpen ? "side" : ""); const isAg = IsAdaptiveAgriculture(); diff --git a/src/navigation/Router.tsx b/src/navigation/Router.tsx index 94ce101..cde11f9 100644 --- a/src/navigation/Router.tsx +++ b/src/navigation/Router.tsx @@ -1,7 +1,7 @@ import { lazy, Suspense } from "react"; import LoadingScreen from "../app/LoadingScreen"; import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; -import { useAuth0 } from "@auth0/auth0-react"; +import { useAuthContext } from "providers/authContext"; import Header from "app/Header"; import Logout from "pages/Logout"; import { ErrorBoundary } from "react-error-boundary"; @@ -57,7 +57,7 @@ export const appendToUrl = (appendage: number | string) => { export default function Router() { - const { /*isAuthenticated, loginWithRedirect,*/ isLoading } = useAuth0(); + const { isAuthenticated } = useAuthContext(); const whiteLabel = getWhitelabel(); const [{ user }] = useGlobalState(); @@ -306,13 +306,7 @@ export default function Router() { ); } - if (isLoading) return null; - // if (!isAuthenticated) { - // loginWithRedirect() - // return ( - // null - // ) - // } + if (!isAuthenticated) return null; function ErrorFallback({ error }: { error: Error }) { return
Something went wrong: {error.stack}
; diff --git a/src/navigation/SideNavigator.tsx b/src/navigation/SideNavigator.tsx index 815f30b..95b2afd 100644 --- a/src/navigation/SideNavigator.tsx +++ b/src/navigation/SideNavigator.tsx @@ -35,7 +35,7 @@ import { IsStreamline, } from "services/whiteLabel"; import MiningIcon from "products/ventilation/MiningIcon"; -import { useAuth0 } from "@auth0/auth0-react"; +import { useAuthContext } from "providers/authContext"; import FieldsIcon from "products/AgIcons/FieldsIcon"; import PlaneIcon from "products/AviationIcons/PlaneIcon"; import AirportMapIcon from "products/AviationIcons/AirportMapIcon"; @@ -130,7 +130,7 @@ interface Props { export default function SideNavigator(props: Props) { const { open, onOpen, onClose } = props; - const { isAuthenticated } = useAuth0() + const { isAuthenticated } = useAuthContext() const theme = useTheme(); const width = useWidth(); const classes = useStyles(); diff --git a/src/pages/Logout.tsx b/src/pages/Logout.tsx index 337c7b6..79e4538 100644 --- a/src/pages/Logout.tsx +++ b/src/pages/Logout.tsx @@ -1,9 +1,9 @@ -import { useAuth0 } from "@auth0/auth0-react"; +import { useAuthContext } from "providers/authContext"; import LoadingScreen from "app/LoadingScreen"; import { useEffect } from "react"; export default function Logout() { - const { logout } = useAuth0(); + const { logout } = useAuthContext(); useEffect(() => { logout(); diff --git a/src/providers/http.tsx b/src/providers/http.tsx index 3e93f04..86ba8b5 100644 --- a/src/providers/http.tsx +++ b/src/providers/http.tsx @@ -2,7 +2,7 @@ import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; import moment from "moment"; import { createContext, PropsWithChildren, useContext } from "react"; import PondProvider from "./pond/pond"; -import { useAuth0 } from "@auth0/auth0-react"; +import { useAuthContext } from "./authContext"; import SnackbarProvider from "./Snackbar"; interface IHTTPContext { @@ -30,7 +30,7 @@ export const HTTPContext = createContext({} as IHTTPContext); export default function HTTPProvider(props: Props) { const { children, token } = props; - const { isAuthenticated, loginWithRedirect } = useAuth0(); + const { isAuthenticated, loginWithRedirect } = useAuthContext(); const defaultOptions = (demo: boolean = false) => { if (demo || !isAuthenticated || !token) { @@ -42,7 +42,7 @@ export default function HTTPProvider(props: Props) { } const config = { - headers: { + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, @@ -50,31 +50,24 @@ export default function HTTPProvider(props: Props) { return config; }; - function isTokenExpired(token: string): boolean { + function isTokenExpired(token: string | undefined): boolean { + if (!token) return true; try { - // Split the token and decode the payload (second part) const payloadBase64 = token.split('.')[1]; - const decodedPayload = atob(payloadBase64); // Decode base64 - const payload = JSON.parse(decodedPayload); - - // Get expiration time (in seconds) + const payload = JSON.parse(atob(payloadBase64)); const exp = payload.exp; - - if (!exp) return true; // No exp field? Treat as expired - - // Current time in seconds - const now = Math.floor(Date.now() / 1000); - - // Compare - return now >= exp; - } catch (error) { - console.error("Invalid token format:", error); - return true; // Err on the side of caution if decoding fails + if (!exp) return true; + return Math.floor(Date.now() / 1000) >= exp; + } catch { + return true; } } function get(url: string, spreadOptions?: AxiosRequestConfig): Promise> { - if (isTokenExpired(token)) loginWithRedirect() + if (isTokenExpired(token)) { + loginWithRedirect(); + return Promise.reject(new Error("token expired")); + } return axios.get(url, {...defaultOptions(), ...spreadOptions}); } @@ -83,6 +76,10 @@ export default function HTTPProvider(props: Props) { data?: any, spreadOptions?: AxiosRequestConfig ): Promise> { + if (isTokenExpired(token)) { + loginWithRedirect(); + return Promise.reject(new Error("token expired")); + } return axios.put(url, data, { ...defaultOptions(), ...spreadOptions }); } @@ -91,10 +88,18 @@ export default function HTTPProvider(props: Props) { data?: any, spreadOptions?: AxiosRequestConfig ): Promise> { + if (isTokenExpired(token)) { + loginWithRedirect(); + return Promise.reject(new Error("token expired")); + } return axios.post(url, data, { ...defaultOptions(), ...spreadOptions }); } function del(url: string, spreadOptions?: AxiosRequestConfig): Promise> { + if (isTokenExpired(token)) { + loginWithRedirect(); + return Promise.reject(new Error("token expired")); + } return axios.delete(url, { ...defaultOptions(), ...spreadOptions }); } diff --git a/src/user/UserMenu.tsx b/src/user/UserMenu.tsx index a65b935..1eb2da5 100644 --- a/src/user/UserMenu.tsx +++ b/src/user/UserMenu.tsx @@ -5,7 +5,7 @@ import { makeStyles } from "@mui/styles"; import { LockOpen, Person, Lock, Settings, SupervisedUserCircle as TeamIcon, ExitToApp, PersonAdd } from "@mui/icons-material"; import { useState } from "react"; import UserTeamName from "./UserTeamName"; -import { useAuth0 } from "@auth0/auth0-react"; +import { useAuthContext } from "providers/authContext"; import UserSettings from "./UserSettings"; import UserAvatar from "./UserAvatar"; import { purple } from "@mui/material/colors"; @@ -77,7 +77,7 @@ export default function UserMenu() { // const { toggleMode } = useThemeMode() const [{ user, team, as }, dispatch] = useGlobalState(); - const { loginWithRedirect } = useAuth0(); + const { loginWithRedirect } = useAuthContext(); const classes = useStyles(); const theme = useTheme(); From f54d4b53758b466242f19657ef2b970373b2edec Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 13 May 2026 14:32:52 -0600 Subject: [PATCH 067/146] changed the orbital camera to allow for an offset without messing with the rotation, added a moisture heatmap, updated the bin3dvisualizer to look cleaner, re-implemented a new version of the dialog box that pops up when clicking a node --- .../CameraControls/OrbitCameraControls.tsx | 35 +- src/3dModels/Shapes/BaseMesh.tsx | 2 +- src/bin/3dView/Data/BuildCableData.ts | 1 + src/bin/3dView/Scene/Bin3dView.tsx | 41 +- src/bin/3dView/Systems/Cables/CableNode.tsx | 6 +- src/bin/3dView/Systems/Cables/GrainCable.tsx | 2 +- .../Systems/Heatmap/MoistureHeatmap.tsx | 446 ++++++++++++++++++ .../3dView/Systems/Heatmap/TempHeatMap.tsx | 31 +- src/bin/binSummary/BinSummary.tsx | 152 +++++- .../binSummary/components/bin3dVisualizer.tsx | 364 ++++++++++---- .../binSummary/components/binTableView.tsx | 1 + .../binSummary/components/nodeControls.tsx | 406 ++++++++++++++++ src/models/Bin.ts | 19 +- src/pages/BinV2.tsx | 45 +- src/pbHelpers/Component.ts | 12 +- 15 files changed, 1349 insertions(+), 214 deletions(-) create mode 100644 src/bin/3dView/Systems/Heatmap/MoistureHeatmap.tsx create mode 100644 src/bin/binSummary/components/nodeControls.tsx diff --git a/src/3dModels/CameraControls/OrbitCameraControls.tsx b/src/3dModels/CameraControls/OrbitCameraControls.tsx index d145caa..a64fc80 100644 --- a/src/3dModels/CameraControls/OrbitCameraControls.tsx +++ b/src/3dModels/CameraControls/OrbitCameraControls.tsx @@ -33,6 +33,11 @@ interface Props { * larger when initialRadius is large. */ maxRadius?: number; + /** + * used to control the offset of the target from the center that the camera rotates around + */ + offset?: { x: number; y: number; z: number }; + viewOffset?: number; /** * Called when the camera is reset to its initial position. * OrbitCameraControls manages the reset internally — use this to @@ -48,6 +53,10 @@ export default function OrbitCameraControls(props: Props) { clampVerticalRotation, minPhi = 0.01, maxPhi = Math.PI - 0.01, + offset, + viewOffset, + initialRadius, + maxRadius, onReset, } = props; @@ -55,9 +64,9 @@ export default function OrbitCameraControls(props: Props) { const mouse = useRef(new Vector2()); const targetRef = useRef({ - x: target?.x ?? 0, - y: target?.y ?? 0, - z: target?.z ?? 0, + x: (target?.x ?? 0) + (offset?.x ?? 0), + y: (target?.y ?? 0) + (offset?.y ?? 0), + z: (target?.z ?? 0) + (offset?.z ?? 0), }); const isDragging = useRef(false); @@ -67,7 +76,7 @@ export default function OrbitCameraControls(props: Props) { // Spherical coords const spherical = useRef({ - radius: props.initialRadius ?? 10, + radius: initialRadius ?? 10, theta: 0, // horizontal angle phi: Math.PI / 2, // vertical angle }); @@ -78,11 +87,11 @@ export default function OrbitCameraControls(props: Props) { const updateCamera = () => { const { radius, theta, phi } = spherical.current; - + const x = radius * Math.sin(phi) * Math.sin(theta); const y = radius * Math.cos(phi); const z = radius * Math.sin(phi) * Math.cos(theta); - + camera.position.set( targetRef.current.x + x, targetRef.current.y + y, @@ -93,19 +102,23 @@ export default function OrbitCameraControls(props: Props) { targetRef.current.y, targetRef.current.z ); + + // Shift the projected image without affecting orbit + const { width, height } = gl.domElement.getBoundingClientRect(); + (camera as any).setViewOffset(width, height, viewOffset ?? 0, 0, width, height); }; // Hand the reset function to the caller so they can trigger it // (e.g. from a button in CameraOverlay) without needing an external ref. if (onReset) { onReset(() => { - spherical.current.radius = props.initialRadius ?? 10; + spherical.current.radius = initialRadius ?? 10; spherical.current.theta = 0; spherical.current.phi = Math.PI / 2; targetRef.current = { - x: target?.x ?? 0, - y: target?.y ?? 0, - z: target?.z ?? 0, + x: (target?.x ?? 0) + (offset?.x ?? 0), + y: (target?.y ?? 0) + (offset?.y ?? 0), + z: (target?.z ?? 0) + (offset?.z ?? 0), }; updateCamera(); }); @@ -200,7 +213,7 @@ export default function OrbitCameraControls(props: Props) { spherical.current.radius += e.deltaY * 0.01; spherical.current.radius = Math.max( 3, - Math.min(props.maxRadius ?? 30, spherical.current.radius) + Math.min(maxRadius ?? 30, spherical.current.radius) ); updateCamera(); diff --git a/src/3dModels/Shapes/BaseMesh.tsx b/src/3dModels/Shapes/BaseMesh.tsx index 73ea434..e468c0c 100644 --- a/src/3dModels/Shapes/BaseMesh.tsx +++ b/src/3dModels/Shapes/BaseMesh.tsx @@ -79,7 +79,7 @@ export default function BaseMesh(props: BaseMeshProps) { }; return ( - + {/* Main surface */} {buildMaterial()} diff --git a/src/bin/3dView/Data/BuildCableData.ts b/src/bin/3dView/Data/BuildCableData.ts index f9493a0..3b2c673 100644 --- a/src/bin/3dView/Data/BuildCableData.ts +++ b/src/bin/3dView/Data/BuildCableData.ts @@ -1,4 +1,5 @@ import { Bin } from "models"; +import { GrainCable } from "models/GrainCable"; import { pond } from "protobuf-ts/pond"; import { Vector3 } from "three"; import { avg } from "utils"; diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index cc2ae26..551e98e 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -15,10 +15,18 @@ import GrainCableFill from "../Systems/Inventory/GrainCableFill"; import TempHeatMap from "../Systems/Heatmap/TempHeatMap"; import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; import { Box } from "@mui/material"; +import MoistureHeatMap from "../Systems/Heatmap/MoistureHeatmap"; +// import { GrainCable } from "models/GrainCable"; interface Props { bin: Bin scale: number + /** + * cables can come from the bins status when not passed in, however data may be out of synce from when the cables read to when the bins status updates + * they may need to be passed in but for the moment we wont worry about it due to the last active on the bin page can be used to explain being out of sync + * if it becomes an issue I can make the changes necessary to pass the cable components in + */ + // cables?: GrainCable[] fillPercent?: number nodeClick?: (node: NodeData, cable: CableData) => void showGrain?: boolean @@ -31,10 +39,11 @@ interface Props { showResetButton?: boolean showTemp?: boolean showMoisture?: boolean + xOffset?: number } export default function Bin3dView(props: Props) { - const { bin, scale = 100, fillPercent, nodeClick, showTempHeatmap, showGrain, showHotspots, showResetButton, showMoistureHeatmap, showTemp, showMoisture } = props + const { bin, scale = 100, fillPercent, nodeClick, showTempHeatmap, showGrain, showHotspots, showResetButton, showMoistureHeatmap, showTemp, showMoisture, xOffset } = props // Compute the initial camera radius so the full bin fits in frame. // Uses the perspective camera FOV (default 75°) with a padding factor. @@ -105,7 +114,14 @@ export default function Bin3dView(props: Props) { } return ( - + + { resetCameraFn.current = fn; }} + viewOffset={xOffset} + /> { if (nodeClick) nodeClick(node, cable) }} - renderOrder={1} + renderOrder={5} /> {showHotspots && } + {/* note that the heatmaps insternally use render order 1,2, and 3 for the three seperate coloured meshes */} {showTempHeatmap && ( )} + {showMoistureHeatmap && ( + + )} @@ -151,16 +175,7 @@ export default function Bin3dView(props: Props) { - { resetCameraFn.current = fn; }} - /> - {/* resetCameraFn.current?.()} - /> */} + ) } diff --git a/src/bin/3dView/Systems/Cables/CableNode.tsx b/src/bin/3dView/Systems/Cables/CableNode.tsx index ecaaf18..08fa571 100644 --- a/src/bin/3dView/Systems/Cables/CableNode.tsx +++ b/src/bin/3dView/Systems/Cables/CableNode.tsx @@ -146,10 +146,12 @@ export default function CableNode(props: Props) { colour={nodeColour()} onClick={handleClick} renderOrder={renderOrder} - + depthTest={false} + depthWrite={false} + opacity={0.99} /> {node.topNode && ( - + )} {node.inGrain && showLabel && diff --git a/src/bin/3dView/Systems/Cables/GrainCable.tsx b/src/bin/3dView/Systems/Cables/GrainCable.tsx index 7c3aa3b..62ca26f 100644 --- a/src/bin/3dView/Systems/Cables/GrainCable.tsx +++ b/src/bin/3dView/Systems/Cables/GrainCable.tsx @@ -41,7 +41,7 @@ export default function GrainCable(props: Props) { return ( - + {nodes.map((node, i) => ( ))} diff --git a/src/bin/3dView/Systems/Heatmap/MoistureHeatmap.tsx b/src/bin/3dView/Systems/Heatmap/MoistureHeatmap.tsx new file mode 100644 index 0000000..290afce --- /dev/null +++ b/src/bin/3dView/Systems/Heatmap/MoistureHeatmap.tsx @@ -0,0 +1,446 @@ +import { useMemo } from "react"; +import * as THREE from "three"; +import { Bin } from "models"; +import { NodeData } from "../../Data/BuildNodeData"; +import React from "react"; + +interface Props { + bin: Bin; + nodes: NodeData[]; + /** + * For flat fill percent inventory — a scalar Y ceiling for the heatmap top. + * Used when there are no cable top nodes to drive a surface. + */ + flatMaxY?: number; +} + +const RADIAL_RINGS = 20; +const THETA_SEGMENTS = 40; +const HEIGHT_STEPS = 28; + +const YELLOW_DELTA = 2; +const RED_DELTA = 4; + +const IDW_POWER = 4; +const RED_OPACITY = 0.3; +const GREEN_OPACITY = 0.3; +const YELLOW_OPACITY = 0.8; + +const ANGLE_JITTER = 0.2; +const RADIAL_JITTER = 0.05; +const LAYER_TWIST = 0.22; + +// IDW power for the horizontal surface interpolation — +// matches GrainCableFill's default of 2 +const SURFACE_IDW_POWER = 2; + +function moistureToHeat(moisture: number, target: number): number { + if (moisture <= target) return 0; + const delta = moisture - target; + if (delta >= RED_DELTA) return 2; + if (delta >= YELLOW_DELTA) return 1 + (delta - YELLOW_DELTA) / (RED_DELTA - YELLOW_DELTA); + return delta / YELLOW_DELTA; +} + +function heatToRGB(heat: number): number[] { + const GREEN = [0, 0.5, 0.02]; + const YELLOW = [0.7, 0.86, 0.0]; + const RED = [0.8, 0.0, 0.01]; + + if (heat <= 0) return GREEN; + + if (heat <= 1) { + const t = Math.pow(heat, 0.75); + return [ + GREEN[0] + (YELLOW[0] - GREEN[0]) * t, + GREEN[1] + (YELLOW[1] - GREEN[1]) * t, + GREEN[2] + (YELLOW[2] - GREEN[2]) * t, + ]; + } + + const t = Math.pow(heat - 1, 0.75); + return [ + YELLOW[0] + (RED[0] - YELLOW[0]) * t, + YELLOW[1] + (RED[1] - YELLOW[1]) * t, + YELLOW[2] + (RED[2] - YELLOW[2]) * t, + ]; +} + +interface MoistureAnchor { + x: number; y: number; z: number; + moisture: number; +} + +interface SurfaceAnchor { + x: number; z: number; y: number; +} + +// 3D IDW for moisture interpolation +function idwMoisture( + px: number, py: number, pz: number, + anchors: MoistureAnchor[], + power: number +): number { + let totalWeight = 0; + let weightedSum = 0; + + for (const a of anchors) { + const dx = px - a.x; + const dy = py - a.y; + const dz = pz - a.z; + const distSq = dx * dx + dy * dy + dz * dz; + if (distSq < 0.001) return a.moisture; + const weight = 1 / Math.pow(distSq, power / 2); + totalWeight += weight; + weightedSum += a.moisture * weight; + } + + return totalWeight === 0 ? 0 : weightedSum / totalWeight; +} + +// 2D IDW for surface height interpolation — matches GrainCableFill exactly +function idwSurfaceY( + x: number, z: number, + anchors: SurfaceAnchor[], + power: number +): number { + let totalWeight = 0; + let weightedY = 0; + + for (const a of anchors) { + const dx = x - a.x; + const dz = z - a.z; + const distSq = dx * dx + dz * dz; + if (distSq < 0.001) return a.y; + const weight = 1 / Math.pow(distSq, power / 2); + totalWeight += weight; + weightedY += a.y * weight; + } + + return totalWeight === 0 ? 0 : weightedY / totalWeight; +} + +function hashNoise(a: number, b: number, c: number) { + const x = Math.sin(a * 127.1 + b * 311.7 + c * 74.7) * 43758.5453; + return (x - Math.floor(x)) * 2 - 1; +} + +export default function MoistureHeatMap({ bin, nodes, flatMaxY }: Props) { + const binRadius = bin.diameter() / 2; + const sidewallHeight = bin.sidewallHeight(); + const hopperHeight = bin.hopperHeight() ?? 0; + const targetMoisture = bin.targetMoisture(); + + const sidewallBaseY = -sidewallHeight / 2; + const hopperTipY = sidewallBaseY - hopperHeight; + + const maxRadiusAtY = (y: number) => { + if (y >= sidewallBaseY) return binRadius; + if (hopperHeight <= 0 || y <= hopperTipY) return 0; + return binRadius * ((y - hopperTipY) / hopperHeight); + }; + + // Moisture anchors — all in-grain nodes that have a moisture reading + const moistureAnchors = useMemo( + () => + nodes + .filter(n => n.inGrain && !n.excluded && n.moisture !== undefined) + .map(n => ({ + x: n.position.x, + y: n.position.y, + z: n.position.z, + moisture: n.moisture as number, + })), + [nodes] + ); + + // Surface anchors — top nodes only, Y offset by half spacing. + // Only used when flatMaxY is NOT provided (i.e. Automatic / Hybrid_Cable + // inventory control). For any other control type the caller passes flatMaxY + // and we must use a flat surface instead of the wavy cable-driven one. + const surfaceAnchors = useMemo(() => { + if (flatMaxY !== undefined) { + // Non-cable inventory: force flat surface — ignore cable top nodes + return []; + } + return nodes + .filter(n => n.topNode && n.inGrain && !n.excluded) + .map(n => ({ + x: n.position.x, + z: n.position.z, + y: n.position.y + n.nodeSpacing * 0.5, + })); + }, [nodes, flatMaxY]); + + // Wall clamp Y — average of surface anchor heights. + // Prevents the surface from piling up at the bin wall where there are no cables. + // Matches GrainCableFill's wallY computation. + const wallY = useMemo(() => { + if (surfaceAnchors.length === 0) return flatMaxY ?? sidewallBaseY; + return surfaceAnchors.reduce((sum, a) => sum + a.y, 0) / surfaceAnchors.length; + }, [surfaceAnchors, flatMaxY, sidewallBaseY]); + + // For each (x, z) position, returns the Y ceiling of the grain surface. + // Uses cable surface IDW when top nodes exist, falls back to flatMaxY. + const getSurfaceY = (x: number, z: number): number => { + if (surfaceAnchors.length > 0) { + // Outermost ring clamps to wallY — matches GrainCableFill + const raw = idwSurfaceY(x, z, surfaceAnchors, SURFACE_IDW_POWER); + return Math.max(sidewallBaseY, Math.min(sidewallHeight / 2, raw)); + } + return flatMaxY ?? sidewallBaseY; + }; + + // Global max Y — highest point of the surface (used for grid bottom calc) + const maxGrainY = useMemo(() => { + if (surfaceAnchors.length > 0) { + return Math.max(...surfaceAnchors.map(a => a.y), wallY); + } + return flatMaxY ?? sidewallBaseY; + }, [surfaceAnchors, wallY, flatMaxY, sidewallBaseY]); + + const geometry = useMemo(() => { + if (!moistureAnchors.length) return null; + + const pointsPerLayer = 1 + RADIAL_RINGS * THETA_SEGMENTS; + const totalVerts = HEIGHT_STEPS * pointsPerLayer + 1; + + const positions = new Float32Array(totalVerts * 3); + const colors = new Float32Array(totalVerts * 3); + const heats = new Float32Array(totalVerts); + + const rawBottomY = hopperHeight > 0 ? hopperTipY : sidewallBaseY; + const grainBottomY = hopperHeight > 0 + ? hopperTipY + (maxGrainY - hopperTipY) / HEIGHT_STEPS + : sidewallBaseY; + const grainHeight = maxGrainY - grainBottomY; + + if (grainHeight <= 0) return null; + + // Pre-compute the (x, z) position for each (ring, seg) slot so we + // can look up the surface Y ceiling per column + const colX: number[] = new Array(RADIAL_RINGS * THETA_SEGMENTS); + const colZ: number[] = new Array(RADIAL_RINGS * THETA_SEGMENTS); + const colSurfaceY: number[] = new Array(RADIAL_RINGS * THETA_SEGMENTS); + + // Use the outermost layer's radius for surface Y lookup (no jitter/twist) + // so the surface shape matches GrainCableFill cleanly + const topLayerAllowedRadius = maxRadiusAtY(maxGrainY) * 0.97; + + for (let ring = 0; ring < RADIAL_RINGS; ring++) { + const u = (ring + 1) / RADIAL_RINGS; + const rFrac = 1 - Math.pow(1 - u, 2.2); + const r = rFrac * topLayerAllowedRadius; + + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const angle = (seg / THETA_SEGMENTS) * Math.PI * 2; + const x = Math.cos(angle) * r; + const z = Math.sin(angle) * r; + const ci = ring * THETA_SEGMENTS + seg; + colX[ci] = x; + colZ[ci] = z; + // Outermost ring uses wallY, inner rings use full IDW surface + colSurfaceY[ci] = ring === RADIAL_RINGS - 1 + ? wallY + : getSurfaceY(x, z); + } + } + // Center column surface Y + const centerSurfaceY = getSurfaceY(0, 0); + + for (let hStep = 0; hStep < HEIGHT_STEPS; hStep++) { + const t = hStep / (HEIGHT_STEPS - 1); + + const layerBase = hStep * pointsPerLayer; + const allowedRadius = maxRadiusAtY(grainBottomY + t * grainHeight) * 0.97; + + // Center vertex — Y is lerped from bottom to its column surface ceiling + const centerY = grainBottomY + t * (centerSurfaceY - grainBottomY); + + const centerMoisture = idwMoisture(0, centerY, 0, moistureAnchors, IDW_POWER); + const centerHeat = moistureToHeat(centerMoisture, targetMoisture); + const [cr, cg, cb] = heatToRGB(centerHeat); + + positions[layerBase * 3] = 0; + positions[layerBase * 3 + 1] = centerY; + positions[layerBase * 3 + 2] = 0; + colors[layerBase * 3] = cr; + colors[layerBase * 3 + 1] = cg; + colors[layerBase * 3 + 2] = cb; + heats[layerBase] = centerHeat; + + for (let ring = 0; ring < RADIAL_RINGS; ring++) { + const u = (ring + 1) / RADIAL_RINGS; + const rFrac = 1 - Math.pow(1 - u, 2.2); + + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const noiseA = hashNoise(hStep, ring, seg); + const noiseR = hashNoise(seg, hStep, ring + 11); + + const baseRadius = rFrac * allowedRadius; + const jitterRadius = baseRadius + noiseR * RADIAL_JITTER * allowedRadius; + const r = Math.max(0, Math.min(jitterRadius, allowedRadius)); + + const layerPhase = t * LAYER_TWIST; + const angle = + (seg / THETA_SEGMENTS) * Math.PI * 2 + + layerPhase + + noiseA * ANGLE_JITTER; + + const x = Math.cos(angle) * r; + const z = Math.sin(angle) * r; + + // Per-column surface Y ceiling — this is what gives the wavy top + const ci = ring * THETA_SEGMENTS + seg; + const surfaceY = colSurfaceY[ci]; + + // Lerp this vertex Y from grainBottomY up to its column surface ceiling + const y = grainBottomY + t * (surfaceY - grainBottomY); + + const moisture = idwMoisture(x, y, z, moistureAnchors, IDW_POWER); + const heat = moistureToHeat(moisture, targetMoisture); + const [vr, vg, vb] = heatToRGB(heat); + + const vi = layerBase + 1 + ring * THETA_SEGMENTS + seg; + + heats[vi] = heat; + positions[vi * 3] = x; + positions[vi * 3 + 1] = y; + positions[vi * 3 + 2] = z; + colors[vi * 3] = vr; + colors[vi * 3 + 1] = vg; + colors[vi * 3 + 2] = vb; + } + } + } + + const idx = (hStep: number, ring: number, seg: number) => { + if (ring < 0) return hStep * pointsPerLayer; + const s = ((seg % THETA_SEGMENTS) + THETA_SEGMENTS) % THETA_SEGMENTS; + return hStep * pointsPerLayer + 1 + ring * THETA_SEGMENTS + s; + }; + + const green: number[] = []; + const yellow: number[] = []; + const red: number[] = []; + + function pushTri(a: number, b: number, c: number) { + const avg = (heats[a] + heats[b] + heats[c]) / 3; + if (avg < 1) green.push(a, b, c); + else if (avg < 2) yellow.push(a, b, c); + else red.push(a, b, c); + } + + for (let h = 0; h < HEIGHT_STEPS - 1; h++) { + for (let ring = 0; ring < RADIAL_RINGS - 1; ring++) { + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + const a = idx(h, ring, seg); + const b = idx(h, ring, next); + const e = idx(h + 1, ring, seg); + const f = idx(h + 1, ring, next); + pushTri(a, e, b); + pushTri(e, f, b); + } + } + + // center fill + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + const center0 = idx(h, -1, 0); + const center1 = idx(h + 1, -1, 0); + const a = idx(h, 0, seg); + const b = idx(h, 0, next); + const c = idx(h + 1, 0, seg); + const d = idx(h + 1, 0, next); + pushTri(center0, c, a); + pushTri(center0, center1, c); + pushTri(a, c, b); + pushTri(b, c, d); + } + } + + // Hopper tip + const tipVertexIndex = HEIGHT_STEPS * pointsPerLayer; + if (hopperHeight > 0) { + const tipMoisture = idwMoisture(0, rawBottomY, 0, moistureAnchors, IDW_POWER); + const tipHeat = moistureToHeat(tipMoisture, targetMoisture); + const [tr, tg, tb] = heatToRGB(tipHeat); + + positions[tipVertexIndex * 3] = 0; + positions[tipVertexIndex * 3 + 1] = rawBottomY; + positions[tipVertexIndex * 3 + 2] = 0; + colors[tipVertexIndex * 3] = tr; + colors[tipVertexIndex * 3 + 1] = tg; + colors[tipVertexIndex * 3 + 2] = tb; + heats[tipVertexIndex] = tipHeat; + + const outerRing = RADIAL_RINGS - 1; + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + pushTri(tipVertexIndex, idx(0, outerRing, next), idx(0, outerRing, seg)); + } + } + + function makeGeo(indices: number[]) { + const g = new THREE.BufferGeometry(); + g.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + g.setAttribute("color", new THREE.BufferAttribute(colors, 3)); + g.setIndex(indices); + return g; + } + + return { + green: makeGeo(green), + yellow: makeGeo(yellow), + red: makeGeo(red), + }; + }, [ + moistureAnchors, + surfaceAnchors, + wallY, + maxGrainY, + hopperTipY, + sidewallBaseY, + binRadius, + targetMoisture, + hopperHeight, + flatMaxY, + ]); + + if (!geometry) return null; + + return ( + + + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx b/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx index ee6e46c..8e70a79 100644 --- a/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx +++ b/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx @@ -154,19 +154,23 @@ export default function TempHeatMap({ bin, nodes, flatMaxY }: Props) { [nodes] ); - // Surface anchors — top nodes only, Y offset by half spacing - // Matches GrainCableFill's anchor computation exactly. - const surfaceAnchors = useMemo( - () => - nodes - .filter(n => n.topNode && n.inGrain && !n.excluded) - .map(n => ({ - x: n.position.x, - z: n.position.z, - y: n.position.y + n.nodeSpacing * 0.5, - })), - [nodes] - ); + // Surface anchors — top nodes only, Y offset by half spacing. + // Only used when flatMaxY is NOT provided (i.e. Automatic / Hybrid_Cable + // inventory control). For any other control type the caller passes flatMaxY + // and we must use a flat surface instead of the wavy cable-driven one. + const surfaceAnchors = useMemo(() => { + if (flatMaxY !== undefined) { + // Non-cable inventory: force flat surface — ignore cable top nodes + return []; + } + return nodes + .filter(n => n.topNode && n.inGrain && !n.excluded) + .map(n => ({ + x: n.position.x, + z: n.position.z, + y: n.position.y + n.nodeSpacing * 0.5, + })); + }, [nodes, flatMaxY]); // Wall clamp Y — average of surface anchor heights. // Prevents the surface from piling up at the bin wall where there are no cables. @@ -440,3 +444,4 @@ export default function TempHeatMap({ bin, nodes, flatMaxY }: Props) { ); } + diff --git a/src/bin/binSummary/BinSummary.tsx b/src/bin/binSummary/BinSummary.tsx index 737f125..9621d8b 100644 --- a/src/bin/binSummary/BinSummary.tsx +++ b/src/bin/binSummary/BinSummary.tsx @@ -1,31 +1,67 @@ -import { Box, Card, Grid2 } from "@mui/material"; +import { Box, Card, Grid2, LinearProgress, Typography } from "@mui/material"; import { useMobile } from "hooks"; -import { Bin } from "models"; +import { Bin, Component, Device } from "models"; import Bin3dVisualizer from "./components/bin3dVisualizer"; import BinTableView from "./components/binTableView"; +// import GrassIcon from "@mui/icons-material/Grass" +import { grey, orange, red } from "@mui/material/colors"; +import { AccessTime, Spa } from "@mui/icons-material"; +import moment from "moment"; +import { useEffect, useState } from "react"; +import { Controller } from "models/Controller"; +import { GrainCable } from "models/GrainCable"; +import { pond } from "protobuf-ts/pond"; interface Props { bin: Bin + devices: Device[] + cables?: GrainCable[] + fans?: Controller[] + heaters?: Controller[] + permissions: pond.Permission[] + componentDevices: Map + componentMap: Map } export default function BinSummary(props: Props){ - const {bin} = props + const {bin, devices, fans, heaters, permissions, componentDevices, componentMap} = props + const [currentDevice, setCurrentDevice] = useState(undefined) const isMobile = useMobile() - //need to decide if the vies shoulw both be displayed or toggled between - const bin3dView = () => { - return ( - - ) - } - const tableView = () => { - return ( - - ) - } + useEffect(() => { + if(devices.length > 0){ + setCurrentDevice(devices[0]) + } + },[devices]) + + const activity = (reading: string) => { + //if the device hasnt checked in in 6 hours = missing 12 hours = inactive within 6 hours = live + + let status = "Live" + let colour = "#4caf50" + let diff = moment().diff(moment(reading), "hour") + console.log(diff) + if(diff > 12){ + status = "Inactive" + colour = red[700] + }else if (diff > 6){ + status = "Missing" + colour = orange[300] + } - const inventoryOverView = () => { return ( - inventory overview + + + + {status} + + ) } @@ -45,20 +81,86 @@ export default function BinSummary(props: Props){ return ( + + + + + Grain Type + + + + {/* */} + {bin.grainName()} + + + + + Bin Fill + + + + {bin.binFillCap()} + + + + + + + Last Updated + + {/* this would be to show the last time the device checked in */} + {/* {currentDevice ? + + + + + {moment(currentDevice.status.lastActive).fromNow()} + + + {activity(currentDevice.status.lastActive)} + + : + + + No Connected Devices Found + + + } */} + {/* this shows the last time the bins status updated */} + + + + + {moment(bin.status.timestamp).fromNow()} + + + {activity(bin.status.timestamp)} + + + + - + - {bin3dView()} + - + - {tableView()} - - - - - {inventoryOverView()} + diff --git a/src/bin/binSummary/components/bin3dVisualizer.tsx b/src/bin/binSummary/components/bin3dVisualizer.tsx index 5da0a3e..295374c 100644 --- a/src/bin/binSummary/components/bin3dVisualizer.tsx +++ b/src/bin/binSummary/components/bin3dVisualizer.tsx @@ -1,130 +1,288 @@ -import { Box, Checkbox, FormControlLabel, List, ListItem } from "@mui/material"; +import { Box, Checkbox, FormControl, MenuItem, List, ListItem, InputLabel, Select, Typography, Stack } from "@mui/material"; import Bin3dView from "bin/3dView/Scene/Bin3dView"; -import { Bin } from "models"; -import React from "react"; +import { Bin, Component, Device } from "models"; +import { Controller } from "models/Controller"; +import { pond, quack } from "protobuf-ts/pond"; +import React, { useEffect } from "react"; import { useState } from "react"; +import NodeControls from "./nodeControls"; +import { NodeData } from "bin/3dView/Data/BuildNodeData"; +import { CableData } from "bin/3dView/Data/BuildCableData"; interface Props { bin: Bin + // cables?: GrainCable[] + devices: Device[] + fans?: Controller[] + heaters?: Controller[] + permissions: pond.Permission[] + componentDevices: Map + componentMap: Map } export default function bin3dVisualizer(props: Props){ - const {bin} = props + const {bin, fans, heaters, permissions, devices, componentDevices, componentMap} = props const [showHeatmap, setShowHeatmap] = useState(true) const [showHotspots, setShowHotspots] = useState(false) const [showFill, setShowFill] = useState(false) - const [showTemp, setShowTemp] = useState(false) + const [showTemp, setShowTemp] = useState(true) const [showMoisture, setShowMoisture] = useState(false) + const [showMoistureHeatmap, setShowMoistureHeatmap] = useState(false) + const [binDisplay, setBinDisplay] = useState("temp") + const [binMode, setBinMode] = useState(pond.BinMode.BIN_MODE_NONE) + const [selectedCable, setSelectedCable] = useState(undefined) + const [selectedNode, setSelectedNode] = useState(undefined) + const [selectedCableDevice, setSelectedCableDevice] = useState(Device.create()) //this is the device that the cable that has the node that was clicked on belongs to + const [filteredComponents, setFilteredComponents] = useState([]) //components that are filtered to only include ones on the same device + const [openNodeControls, setOpenNodeControls] = useState(false) + const [devMap, setDevMap] = useState>(new Map()) - const getFill = () => { - //calculate the fill percent of the bin when using manual/lidar for inventory + useEffect(()=>{ + let newMap: Map = new Map() + devices.forEach(d => { + newMap.set(d.id(), d) + }) + setDevMap(newMap) + },[devices]) - return 0 - } + useEffect(()=>{ + setBinMode(bin.settings.mode) + },[bin]) - const controlsOverlay = () => { + const binModeControl = () => { return ( - - - - { - setShowHeatmap(checked); - }} - /> - } - label={"Toggle Heatmap"} - /> - - - { - setShowHotspots(checked); - }} - /> - } - label={"Toggle Hotspots"} - /> - - - { - setShowFill(checked); - }} - /> - } - label={"Toggle Hotspots"} - /> - - - { - setShowTemp(checked); - }} - /> - } - label={"Toggle Temp"} - /> - - - - { - setShowMoisture(checked); - }} - /> - } - label={"Toggle Moisture"} - /> - - - - - - - + + {bin.name()} + + + + ) } - const binViewer = () => { + const heatmapDisplay = () => { return ( - + + Heatmap Display + + {/* Display */} + + + + ) + } + + const controllerState = (controller: Controller) => { + let state = false + controller.status.lastGoodMeasurement.forEach(um => { + if(um.type === quack.MeasurementType.MEASUREMENT_TYPE_BOOLEAN){ + if(um.values.length > 0){ + let readings = um.values + if(readings[readings.length-1].values.length > 0){ + let nodes = readings[readings.length-1].values + if (nodes[nodes.length -1] === 1){ + state = true + } + } + } + } + }) + return state + } + + const controllerDisplay = () => { + return ( + + + Controllers + {fans && fans.map(fan => { + let isOn = controllerState(fan) + return ( + + {fan.name()} + + + {isOn ? "ON" : "OFF"} + + + + + )})} + {heaters && heaters.map(heater => { + let isOn = controllerState(heater) + return ( + + {heater.name()} + + + {isOn ? "ON" : "OFF"} + + + + + )})} + + + // loop through controllers displaying each one + ) } return ( - - {binViewer()} - - {controlsOverlay()} + {(selectedCable && selectedNode) && + { + setOpenNodeControls(false) + }} + /> + } + + { + // console.log(node) + // console.log(cable) + //this will be how to control the dialog to update the top nodes and node exclusion etc (make new component called NodeControls for this) + //use the cable data to get the device it belongs to + let d = devMap.get(cable.grainCable.device) + if(d !== undefined){ + setSelectedNode(node) + setSelectedCable(cable) + setSelectedCableDevice(d) + //filter the controls so that only components on THIS device are options for new interactions, a side note of this using the components that are in the bins status + //is that it will indirectly also filter by bin as well so only components that are both on the bin and the same device as the cable will appear + let filtered: Component[] = [] + if(fans){ + fans.forEach((f) => { + if(componentDevices.get(f.key()) === d.id()){ + filtered.push(f.asComponent()) + } + }) + } + if(heaters){ + heaters.forEach((h) => { + if(componentDevices.get(h.key()) === d.id()){ + filtered.push(h.asComponent()) + } + }) + } + //also make sure to push the cable into the list of filtered components for the source + let c = componentMap.get(cable.grainCable.key) + if(c !== undefined){ + filtered.push(c) + } + setFilteredComponents(filtered) + setOpenNodeControls(true) + } + }} + scale={100} + showTempHeatmap={showHeatmap} + showMoistureHeatmap={showMoistureHeatmap} + showTemp={showTemp} + showMoisture={showMoisture} + showGrain={showFill} + showHotspots={showHotspots} + xOffset={-150} + /> + + + {binModeControl()} + {heatmapDisplay()} + {controllerDisplay()} + diff --git a/src/bin/binSummary/components/binTableView.tsx b/src/bin/binSummary/components/binTableView.tsx index daa76ff..94721dd 100644 --- a/src/bin/binSummary/components/binTableView.tsx +++ b/src/bin/binSummary/components/binTableView.tsx @@ -94,6 +94,7 @@ export default function BinTableView(props: Props) { //determine the temp if(level.grainTemps.length > 0){ + console.log(level.grainTemps) lvlTemp = avg(level.grainTemps) //if the average temp is 5 degrees above the bins threshold if(lvlTemp > (bin.upperTempThreshold() + 5)){ diff --git a/src/bin/binSummary/components/nodeControls.tsx b/src/bin/binSummary/components/nodeControls.tsx new file mode 100644 index 0000000..2a8966a --- /dev/null +++ b/src/bin/binSummary/components/nodeControls.tsx @@ -0,0 +1,406 @@ +import { AppBar, Box, Button, Card, CardActionArea, Checkbox, DialogActions, DialogContent, DialogTitle, FormControlLabel, Grid2, IconButton, Typography } from "@mui/material"; +import { green } from "@mui/material/colors"; +import { makeStyles } from "@mui/styles"; +import { CableData } from "bin/3dView/Data/BuildCableData"; +import { NodeData } from "bin/3dView/Data/BuildNodeData"; +import ResponsiveDialog from "common/ResponsiveDialog"; +import HumidityIcon from "component/HumidityIcon"; +import TemperatureIcon from "component/TemperatureIcon"; +import { useComponentAPI, useMobile, useSnackbar } from "hooks"; +import InteractionsOverview from "interactions/InteractionsOverview"; +import { Bin, Component, Device, Interaction } from "models"; +import moment from "moment"; +import AddIcon from "@mui/icons-material/AddCircle"; +import GraphIcon from "products/CommonIcons/graphIcon"; +import { pond } from "protobuf-ts/pond"; +import { useBinAPI, useGlobalState, useInteractionsAPI } from "providers"; +import React from "react"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { canWrite } from "pbHelpers/Permission"; +import InteractionSettings from "interactions/InteractionSettings"; + +const useStyles = makeStyles(() => { + return ({ + dialog: { + maxWidth: 350 + }, + appBar: { + position: "static", + borderRadius: 30 + }, + readingCard: { + padding: 10 + }, + greenButton: { + color: green["600"], + padding: 0 + } + }); + }); + +interface Props { + open: boolean + onClose: () => void + node: NodeData + cable: CableData + device: Device + bin: Bin + /** + * the possible options for controllers when creating new interactions, these controllers must be on the same device as the cable for interactions to work + */ + filteredComponents?: Component[] + componentMap: Map + permissions: pond.Permission[] + /** + * callback function for updating the cable with a new top node or excluded node + * @returns void + */ + updateComponentCallback?: () => void +} + +export default function NodeControls(props: Props){ + const {open, onClose, node, cable, device, bin, filteredComponents, permissions, updateComponentCallback, componentMap} = props + const classes = useStyles() + const [isTopNode, setIsTopNode] = useState(false) + const [isExcluded, setIsExcluded] = useState(false) + const [cableComponent, setCableComponent] = useState(Component.create()) + const [cableInteractions, setCableInteractions] = useState([]) + const isMobile = useMobile() + const navigate = useNavigate() + const [{as, user}] = useGlobalState() + const interactionAPI = useInteractionsAPI() + const binAPI = useBinAPI() + const componentAPI = useComponentAPI() + const {openSnack} = useSnackbar() + const [newInteraction, setNewInteraction] = useState(false); + + //will need to list the interactions for the cable + useEffect(() => { + let component = componentMap.get(cable.grainCable.key) ?? Component.create() + setCableComponent(component) + interactionAPI.listInteractionsByComponent(device.id(), cableComponent.location(), undefined, as).then(resp => { + setCableInteractions(resp); + }); + },[cable, device, interactionAPI]) + + //determine if the node is excluded/the top node + useEffect(()=>{ + if(node.topNode !== undefined){ + setIsTopNode(node.topNode) + } + if(cable.grainCable.excludedNodes.includes(node.nodeIndex)){ + setIsExcluded(true) + }else( + setIsExcluded(false) + ) + },[node, cable]) + + //navigation function to go to the component page + const goToComponent = () => { + navigate("/devices/" + device.id() + "/components/" + cable.grainCable.key); + } + + const close = ()=>{ + onClose() + } + + const setEMC = () => { + let settings = cableComponent.settings; + //make sure the mutation is not there first + if(!settings.defaultMutations.includes(pond.Mutator.MUTATOR_EMC)){ + settings.defaultMutations.push(pond.Mutator.MUTATOR_EMC) + } + settings.grainType = bin.grain(); + settings.customGrain = bin.customGrain(); + componentAPI + .update(device.id(), settings, [bin.key()], ["bin"], as) + .then(resp => { + openSnack("EMC set on cable " + cableComponent.name()); + }) + .catch(err => {}); + } + + const submit = () => { + //should update the component and then update the bin prefs if successful + componentAPI.update(device.id(), cableComponent.settings).then(resp => { + let pref = pond.BinComponentPreferences.create({ + type: pond.BinComponent.BIN_COMPONENT_GRAIN_CABLE, + node: cableComponent.settings.grainFilledTo + }); + //update the bins preferences to have the new top node for the cable + binAPI + .updateComponentPreferences(bin.key(), cableComponent.key(), pref, as) + .then(resp => { + openSnack("Changes will be reflected once the bins status updates") + if(updateComponentCallback){ + updateComponentCallback() + } + }) + .catch(err => {}); + }).catch(err => { + + }) + close() + } + + const updateTopNode = (checked: boolean) => { + if (isTopNode && !checked) { + cableComponent.settings.grainFilledTo = 0; + cableInteractions.forEach(interaction => { + interaction.settings.subtype = 0; + interaction.settings.nodeOne = 0; + interaction.settings.nodeTwo = 0; + }); + } else if (checked) { + cableComponent.settings.grainFilledTo = node.nodeIndex+1; + cableInteractions.forEach(interaction => { + interaction.settings.subtype = Interaction.upToSubtype(node.nodeIndex+1); + interaction.settings.nodeOne = node.nodeIndex+1; + }); + } + setIsTopNode(checked); + } + + const updateNodeExclusion = (checked: boolean) => { + if (checked) { + cableComponent.settings.excludedNodes.push(node.nodeIndex) + } else { + cableComponent.settings.excludedNodes.splice(cableComponent.settings.excludedNodes.indexOf(node.nodeIndex), 1) + } + setIsExcluded(checked) + } + + const displayTemp = () => { + let isF = user.settings.temperatureUnit === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT + let tempFinal = node.celcius ?? 0; + if (isF) { + tempFinal = (tempFinal * 1.8 + 32); + } + return tempFinal.toFixed(2) + (isF ? " F" : "C"); + }; + + const latestReading = () => { + return ( + + Latest Reading + {moment(cable.grainCable.lastRead).fromNow()} + + + + + + + {displayTemp()} + + + + Temperature + + + + + + + + + {node.humidity} % + + + + Humidity + + + + + {/* only show the emc if the bin grain type and the cable grain type match */} + {node.moisture ? ( + + + + + {node.moisture} % + + + + Grain EMC + + + ) : ( + + { + setEMC(); + }}> + + + + + Set EMC + + + + )} + + + + goToComponent()}> + + + + + See Graphs + + + + + + + ); + }; + + const nodeControl = () => { + return ( + + Node Control + { + updateNodeExclusion(e.target.checked); + }} + name="excludedNode" + /> + } + label={ + + + Exclude Node + + + If checked this will set the node to be disabled and will not be used for any calculations done for the bin + + + } + /> + { + updateTopNode(e.target.checked); + }} + name="topNode" + /> + } + label={ + + + Set as top node in grain + + + If checked this will set interactions to ignore all nodes above it. Interactions can + still be adjusted manually to use all nodes + + + } + /> + + ); + }; + + const interactionDisplay = () => { + return ( + + Interactions + + { + interactionAPI + .listInteractionsByComponent(device.id(), cableComponent.location(), undefined, as) + .then(resp => { + setCableInteractions(resp); + }); + }} + /> + + + { + setNewInteraction(true); + }} + className={classes.greenButton}> + + + + + ); + }; + + return ( + + + + + {cable.grainCable.name} + + + + + Node {node.nodeIndex+1} + + + + + + {latestReading()} + {nodeControl()} + {interactionDisplay()} + {/* + */} + + + + + + + { + setNewInteraction(false); + }} + refreshCallback={() => { + interactionAPI.listInteractionsByComponent(device.id(), cableComponent.location(), undefined, as).then(resp => { + setCableInteractions(resp); + }); + }} + canEdit={canWrite(permissions)} + /> + + ) +} \ No newline at end of file diff --git a/src/models/Bin.ts b/src/models/Bin.ts index 6aa2389..1041d86 100644 --- a/src/models/Bin.ts +++ b/src/models/Bin.ts @@ -190,7 +190,12 @@ export class Bin { public grainName(): string { if (this.grain() !== pond.Grain.GRAIN_INVALID) { if (this.grain() === pond.Grain.GRAIN_CUSTOM) { - return this.customType(); + //this will prioritize the new style of custom grain types + let cg = this.customGrain() + if(cg !== undefined){ + return cg.name + } + return this.customType(); //this is still here for bins that are using the old custom grain where it was just a string } else { return GrainDescriber(this.grain()).name; } @@ -199,6 +204,18 @@ export class Bin { } } + public grainGroup(): string { + if (this.grain() !== pond.Grain.GRAIN_INVALID) { + let cg = this.customGrain() + if (this.grain() === pond.Grain.GRAIN_CUSTOM && cg) { + return cg.group + } else { + return GrainDescriber(this.grain()).group; + } + } + return "None"; + } + public binFillCap(): string { let fillCap = ""; if (this.settings.specs && this.settings.inventory) { diff --git a/src/pages/BinV2.tsx b/src/pages/BinV2.tsx index 39691e6..98b0a3c 100644 --- a/src/pages/BinV2.tsx +++ b/src/pages/BinV2.tsx @@ -83,8 +83,6 @@ export default function BinV2(){ const [binPresets, setBinPresets] = useState([]); const [missedReadings, setMissedReadings] = useState(0); - const [currentSection, setCurrentSection] = useState<"binSum">("binSum") - //Drawer states const [noteDrawerOpen, setNoteDrawerOpen] = useState(false) const [taskDrawerOpen, setTaskDrawerOpen] = useState(false) @@ -305,7 +303,7 @@ export default function BinV2(){ */ const pageHeader = () => { return ( - + { - return ( - - ) - } - - /** - * this sections is for showing attached bin components and assigning them to bin positions (see old pages bin components) - * as well as seeing the graph data for attached sensors - */ - const sensorsSection = () => {} - - //Inventory - /** - * this section is for seeing information relating to the bins inventory for its fill level over time and transactions related to inventory of the bin - */ - const InventorySection = () => {} - - //Analysis - /** - * this section is for the analytical charts (drying/hydrating, Moisture trending, Grain Water Content) - */ - const analysisSection = () => {} - - //Interactions - /** - * this is the section for alerts and controls on the bin, if presets get re-implemented they can go here too - */ - const interactionsSection = () => {} - //DRAWERS const noteDrawer = () => { @@ -473,7 +432,7 @@ export default function BinV2(){ return ( {pageHeader()} - {currentSection === "binSum" && binSummarySection()} + {/* render drawers */} {noteDrawer()} {taskDrawer()} diff --git a/src/pbHelpers/Component.ts b/src/pbHelpers/Component.ts index e809649..b357813 100644 --- a/src/pbHelpers/Component.ts +++ b/src/pbHelpers/Component.ts @@ -65,10 +65,20 @@ export function stringToComponentId(componentIDString: string): quack.ComponentI return componentID; } - const componentIDFragments = componentIDString.split("-", 3); + //first split on a colon to get the mux line seperated + const componentSplit = componentIDString.split(":", 2); + if(componentSplit[1]){ + componentID.muxLine = Number(componentSplit[1]) + } + + const componentIDFragments = componentIDString.split("-", 4); let type: string = quack.ComponentType[Number(componentIDFragments[0])]; let addressType: string = quack.AddressType[Number(componentIDFragments[1])]; let address: number = Number(componentIDFragments[2]); + if(componentIDFragments[3]){ + componentID.expansionLine = Number(componentIDFragments[3]) + } + componentID.type = quack.ComponentType[type as keyof typeof quack.ComponentType]; componentID.addressType = quack.AddressType[addressType as keyof typeof quack.AddressType]; componentID.address = address; From 0f4d706e76c24d557d9301559919a54657af17eb Mon Sep 17 00:00:00 2001 From: csawatzky Date: Thu, 14 May 2026 10:39:21 -0600 Subject: [PATCH 068/146] hooked up the mode change stuff for the new summary --- src/bin/binSummary/BinSummary.tsx | 16 ++++- .../binSummary/components/bin3dVisualizer.tsx | 67 ++++++++++++++++--- src/bin/binSummary/components/binDetails.tsx | 17 +++++ src/pages/BinV2.tsx | 20 +++++- 4 files changed, 107 insertions(+), 13 deletions(-) create mode 100644 src/bin/binSummary/components/binDetails.tsx diff --git a/src/bin/binSummary/BinSummary.tsx b/src/bin/binSummary/BinSummary.tsx index 9621d8b..6327cba 100644 --- a/src/bin/binSummary/BinSummary.tsx +++ b/src/bin/binSummary/BinSummary.tsx @@ -21,9 +21,11 @@ interface Props { permissions: pond.Permission[] componentDevices: Map componentMap: Map + binPrefs?: Map + updateBinCallback?: (bin: Bin) => void } export default function BinSummary(props: Props){ - const {bin, devices, fans, heaters, permissions, componentDevices, componentMap} = props + const {bin, devices, fans, heaters, permissions, componentDevices, componentMap, binPrefs, updateBinCallback} = props const [currentDevice, setCurrentDevice] = useState(undefined) const isMobile = useMobile() @@ -155,7 +157,17 @@ export default function BinSummary(props: Props){ - + diff --git a/src/bin/binSummary/components/bin3dVisualizer.tsx b/src/bin/binSummary/components/bin3dVisualizer.tsx index 295374c..67c4a67 100644 --- a/src/bin/binSummary/components/bin3dVisualizer.tsx +++ b/src/bin/binSummary/components/bin3dVisualizer.tsx @@ -8,6 +8,8 @@ import { useState } from "react"; import NodeControls from "./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"; interface Props { bin: Bin @@ -18,23 +20,27 @@ interface Props { permissions: pond.Permission[] componentDevices: Map componentMap: Map + binPrefs?: Map + updateBinCallback?: (bin: Bin) => void } export default function bin3dVisualizer(props: Props){ - const {bin, fans, heaters, permissions, devices, componentDevices, componentMap} = props + const {bin, fans, heaters, permissions, devices, componentDevices, componentMap, binPrefs, updateBinCallback} = props const [showHeatmap, setShowHeatmap] = useState(true) - const [showHotspots, setShowHotspots] = useState(false) - const [showFill, setShowFill] = useState(false) + // 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("temp") const [binMode, setBinMode] = useState(pond.BinMode.BIN_MODE_NONE) + const [openModeChange, setOpenModeChange] = useState(false) const [selectedCable, setSelectedCable] = useState(undefined) const [selectedNode, setSelectedNode] = useState(undefined) const [selectedCableDevice, setSelectedCableDevice] = useState(Device.create()) //this is the device that the cable that has the node that was clicked on belongs to const [filteredComponents, setFilteredComponents] = useState([]) //components that are filtered to only include ones on the same device const [openNodeControls, setOpenNodeControls] = useState(false) const [devMap, setDevMap] = useState>(new Map()) + const [modeChangeInProgress, setModeChangeInProgress] = useState(false) useEffect(()=>{ let newMap: Map = new Map() @@ -48,6 +54,14 @@ export default function bin3dVisualizer(props: Props){ setBinMode(bin.settings.mode) },[bin]) + const updateBin = () => { + if(updateBinCallback){ + let clone = cloneDeep(bin) + clone.settings.mode = binMode + updateBinCallback(clone) + } + }; + const binModeControl = () => { return ( @@ -55,6 +69,7 @@ export default function bin3dVisualizer(props: Props){ { return ( - Heatmap Display + Heatmap Display {/* Display */} { + 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; + + } + }} + > + 1 Week + 2 Weeks + 4 Weeks + Custom + + + {dateSelect === 3 && ( + + )} + + ) +} + return ( - + {datePickerDialog()} + + {/* start/stop button */} + + {isPlayingState ? ( + + ) : ( + + )} + + + {/* progress bar and date display */} + + {currentCheckpointTime ? currentCheckpointTime.format("MMM D, YYYY h:mm A") : moment().format("MMM D, YYYY h:mm A")} + + + {startDate.format("MMM D, YYYY")} + {endDate.format("MMM D, YYYY")} + + + {/* date range selector */} + + {dateSelector()} + + ); } diff --git a/src/bin/bin3dVisualizer.tsx b/src/bin/bin3dVisualizer.tsx index 0ff9766..54a99ac 100644 --- a/src/bin/bin3dVisualizer.tsx +++ b/src/bin/bin3dVisualizer.tsx @@ -1,4 +1,4 @@ -import { Box, Checkbox, FormControl, MenuItem, List, ListItem, InputLabel, Select, Typography, Stack } from "@mui/material"; +import { Box, Checkbox, FormControl, MenuItem, List, ListItem, InputLabel, Select, Typography, Stack, FormControlLabel } from "@mui/material"; import Bin3dView from "bin/3dView/Scene/Bin3dView"; import { Bin, Component, Device } from "models"; import { Controller } from "models/Controller"; @@ -42,6 +42,7 @@ export default function bin3dVisualizer(props: Props){ const [openNodeControls, setOpenNodeControls] = useState(false) const [devMap, setDevMap] = useState>(new Map()) const [modeChangeInProgress, setModeChangeInProgress] = useState(false) + const [showLabels, setShowLabels] = useState(true) const isMobile = useMobile() useEffect(()=>{ @@ -102,7 +103,21 @@ export default function bin3dVisualizer(props: Props){ const heatmapDisplay = () => { return ( - Heatmap Display + + Heatmap Display + { + setShowLabels(checked) + }} + /> + } + label={Show Values} + /> + {/* Display */} { - 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) - } - }}> - Temperature - Moisture - - + + Heatmap + + + + + {/* Checkbox moved below dropdown — no longer affects top alignment */} + setShowLabels(checked)} + /> + } + label={Show Values} + sx={{ mt: 0.5, ml: '-9px' }} + /> ) } diff --git a/src/bin/binSummary/components/binDetails.tsx b/src/bin/binSummary/components/binDetails.tsx index 1e42eee..fc54bd2 100644 --- a/src/bin/binSummary/components/binDetails.tsx +++ b/src/bin/binSummary/components/binDetails.tsx @@ -73,8 +73,15 @@ export default function BinDetails (props: Props) { onChange={(_, value) => setCurrentTab(value)} indicatorColor="primary" textColor="primary" - variant="fullWidth" - sx={{ flexShrink: 0 }}> + variant="scrollable" + scrollButtons="auto" + allowScrollButtonsMobile + sx={{ + flexShrink: 0, + '& .MuiTabs-scroller': { + overflowX: 'auto !important', + } + }}> {isMobile && } From f04213af1e3f86483bcd5089eec72910efb2da37 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 10 Jun 2026 16:42:00 -0600 Subject: [PATCH 109/146] full width on desktop scollable on mobile --- src/bin/binSummary/components/binDetails.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bin/binSummary/components/binDetails.tsx b/src/bin/binSummary/components/binDetails.tsx index fc54bd2..c4abea1 100644 --- a/src/bin/binSummary/components/binDetails.tsx +++ b/src/bin/binSummary/components/binDetails.tsx @@ -73,7 +73,7 @@ export default function BinDetails (props: Props) { onChange={(_, value) => setCurrentTab(value)} indicatorColor="primary" textColor="primary" - variant="scrollable" + variant={isMobile ? "scrollable" : "fullWidth"} scrollButtons="auto" allowScrollButtonsMobile sx={{ From 7b1642bb1a89be33b26f43bc53864db51c631205 Mon Sep 17 00:00:00 2001 From: Carter Date: Thu, 11 Jun 2026 13:37:07 -0600 Subject: [PATCH 110/146] changing user settings defined whitelabel when mismatched, giving team whitelabel if it's empty --- package-lock.json | 977 ++++++++++++++++++------------------- package.json | 2 +- src/app/UserWrapper.tsx | 20 +- src/teams/TeamSettings.tsx | 14 + 4 files changed, 508 insertions(+), 505 deletions(-) diff --git a/package-lock.json b/package-lock.json index 58133fb..a899339 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#sendgrid_whitelabel", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", @@ -128,12 +128,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -200,13 +200,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -333,9 +333,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -356,28 +356,28 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -400,9 +400,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true, "license": "MIT", "engines": { @@ -460,18 +460,18 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -517,12 +517,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -1099,16 +1099,16 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", - "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.7.tgz", + "integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.29.0" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1685,31 +1685,31 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -1717,13 +1717,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -3049,9 +3049,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, "node_modules/@react-pdf/fns": { @@ -3223,9 +3223,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.23.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", - "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -3238,10 +3238,37 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/plugin-babel": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.1.0.tgz", + "integrity": "sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.18.6", + "@rollup/pluginutils": "^5.0.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "@types/babel__core": "^7.1.9", + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "@types/babel__core": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/plugin-node-resolve": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", - "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", "dev": true, "license": "MIT", "dependencies": { @@ -3263,19 +3290,41 @@ } } }, - "node_modules/@rollup/plugin-terser": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", - "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", + "node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", "dev": true, "license": "MIT", "dependencies": { - "serialize-javascript": "^6.0.1", + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-terser": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-1.0.0.tgz", + "integrity": "sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "serialize-javascript": "^7.0.3", "smob": "^1.0.0", "terser": "^5.17.4" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "rollup": "^2.0.0||^3.0.0||^4.0.0" @@ -3287,9 +3336,9 @@ } }, "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", "dev": true, "license": "MIT", "dependencies": { @@ -3448,29 +3497,6 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, - "node_modules/@surma/rollup-plugin-off-main-thread": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", - "integrity": "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "ejs": "^3.1.6", - "json5": "^2.2.0", - "magic-string": "^0.25.0", - "string.prototype.matchall": "^4.0.6" - } - }, - "node_modules/@surma/rollup-plugin-off-main-thread/node_modules/magic-string": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", - "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "sourcemap-codec": "^1.4.8" - } - }, "node_modules/@swc/helpers": { "version": "0.5.18", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", @@ -3492,6 +3518,22 @@ "node": ">=10" } }, + "node_modules/@trickfilm400/rollup-plugin-off-main-thread": { + "version": "3.0.0-pre1", + "resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz", + "integrity": "sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ejs": "^3.1.10", + "json5": "^2.2.3", + "magic-string": "^0.30.21", + "string.prototype.matchall": "^4.0.12" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@turf/along": { "version": "7.3.4", "resolved": "https://registry.npmjs.org/@turf/along/-/along-7.3.4.tgz", @@ -6198,9 +6240,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -6335,15 +6377,15 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -6352,13 +6394,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", + "@vitest/spy": "3.2.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -6379,9 +6421,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", "dev": true, "license": "MIT", "dependencies": { @@ -6392,13 +6434,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", + "@vitest/utils": "3.2.6", "pathe": "^2.0.3", "strip-literal": "^3.0.0" }, @@ -6407,13 +6449,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", + "@vitest/pretty-format": "3.2.6", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -6422,9 +6464,9 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", "dev": true, "license": "MIT", "dependencies": { @@ -6435,13 +6477,13 @@ } }, "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", + "@vitest/pretty-format": "3.2.6", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, @@ -6478,6 +6520,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -6652,14 +6706,15 @@ } }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" } }, "node_modules/axios/node_modules/form-data": { @@ -6809,9 +6864,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -6945,15 +7000,15 @@ } }, "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" }, "engines": { @@ -7263,9 +7318,9 @@ } }, "node_modules/cosmiconfig/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "license": "ISC", "engines": { "node": ">= 6" @@ -7889,9 +7944,9 @@ } }, "node_modules/es-abstract": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", - "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", "dev": true, "license": "MIT", "dependencies": { @@ -7989,9 +8044,9 @@ "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -8284,6 +8339,19 @@ "node": ">=0.10.0" } }, + "node_modules/eta": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-4.6.0.tgz", + "integrity": "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/bgub/eta?sponsor=1" + } + }, "node_modules/eventemitter3": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", @@ -8351,9 +8419,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { @@ -8399,9 +8467,9 @@ } }, "node_modules/filelist": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", - "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -8409,9 +8477,9 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -8481,16 +8549,16 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -8616,6 +8684,21 @@ "node": ">=10" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -8882,16 +8965,16 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" } }, "node_modules/glob/node_modules/minimatch": { @@ -9185,6 +9268,19 @@ "node": ">=10.19.0" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/hyphen": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.14.1.tgz", @@ -10158,15 +10254,15 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", - "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, "node_modules/lodash.debounce": { @@ -11022,9 +11118,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -11087,9 +11183,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -11107,7 +11203,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -11122,9 +11218,9 @@ "license": "MIT" }, "node_modules/postcss/node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -11198,15 +11294,15 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#29d9765ce93c04ad70d795b80e9873fc2fc5f600", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#8c5091affb9c1ef4e80c675072435b745b07e612", "dependencies": { "protobufjs": "^6.8.8" } }, "node_modules/protobufjs": { - "version": "6.11.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.4.tgz", - "integrity": "sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==", + "version": "6.11.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.6.tgz", + "integrity": "sha512-k8BHqgPBOtrlougZZqF2uUk5Z7bN8f0wj+3e8M3hvtSv0NBAz4VBy5f6R5Nxq/l+i7mRFTgNZb2trxqTpHNY/A==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -11230,16 +11326,19 @@ } }, "node_modules/protocol-buffers-schema": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", - "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", "license": "MIT" }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/pump": { "version": "3.0.3", @@ -11311,16 +11410,6 @@ "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==", "license": "MIT" }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/rbush": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz", @@ -11683,12 +11772,12 @@ } }, "node_modules/react-router": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", - "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.2" + "@remix-run/router": "1.23.3" }, "engines": { "node": ">=14.0.0" @@ -11698,13 +11787,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", - "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.2", - "react-router": "6.30.3" + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" }, "engines": { "node": ">=14.0.0" @@ -12190,15 +12279,15 @@ "license": "BSD-3-Clause" }, "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" }, @@ -12304,13 +12393,13 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" + "engines": { + "node": ">=20.0.0" } }, "node_modules/set-function-length": { @@ -12401,15 +12490,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -12421,14 +12510,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -12518,9 +12607,9 @@ "license": "MIT" }, "node_modules/smob": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.1.tgz", - "integrity": "sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.2.tgz", + "integrity": "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==", "dev": true, "license": "MIT", "engines": { @@ -12611,14 +12700,6 @@ "node": ">=0.10.0" } }, - "node_modules/sourcemap-codec": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", - "deprecated": "Please use @jridgewell/sourcemap-codec instead", - "dev": true, - "license": "MIT" - }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -12789,19 +12870,20 @@ } }, "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -12811,16 +12893,16 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "es-object-atoms": "^1.1.2" }, "engines": { "node": ">= 0.4" @@ -13314,18 +13396,18 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" }, "engines": { "node": ">= 0.4" @@ -14380,9 +14462,9 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", "dependencies": { @@ -14543,20 +14625,20 @@ } }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", @@ -14586,8 +14668,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", "happy-dom": "*", "jsdom": "*" }, @@ -14727,14 +14809,14 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", + "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", @@ -14776,30 +14858,30 @@ } }, "node_modules/workbox-background-sync": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.0.tgz", - "integrity": "sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.1.tgz", + "integrity": "sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g==", "dev": true, "license": "MIT", "dependencies": { "idb": "^7.0.1", - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/workbox-broadcast-update": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.0.tgz", - "integrity": "sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.1.tgz", + "integrity": "sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/workbox-build": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.0.tgz", - "integrity": "sha512-Ntk1pWb0caOFIvwz/hfgrov/OJ45wPEhI5PbTywQcYjyZiVhT3UrwwUPl6TRYbTm4moaFYithYnl1lvZ8UjxcA==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.1.tgz", + "integrity": "sha512-SDhxIvEAde9Gy/5w4Yo1Jh/M49Z0qE3q0oteyE8zGq0DScxFqVBcCtIXFuLtmtxRQZCMbf0prco4VyEu3KBQuw==", "dev": true, "license": "MIT", "dependencies": { @@ -14807,39 +14889,39 @@ "@babel/core": "^7.24.4", "@babel/preset-env": "^7.11.0", "@babel/runtime": "^7.11.2", - "@rollup/plugin-babel": "^5.2.0", - "@rollup/plugin-node-resolve": "^15.2.3", - "@rollup/plugin-replace": "^2.4.1", - "@rollup/plugin-terser": "^0.4.3", - "@surma/rollup-plugin-off-main-thread": "^2.2.3", + "@rollup/plugin-babel": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-replace": "^6.0.3", + "@rollup/plugin-terser": "^1.0.0", + "@trickfilm400/rollup-plugin-off-main-thread": "^3.0.0-pre1", "ajv": "^8.6.0", "common-tags": "^1.8.0", + "eta": "^4.5.1", "fast-json-stable-stringify": "^2.1.0", "fs-extra": "^9.0.1", "glob": "^11.0.1", - "lodash": "^4.17.20", "pretty-bytes": "^5.3.0", - "rollup": "^2.79.2", + "rollup": "^4.53.3", "source-map": "^0.8.0-beta.0", "stringify-object": "^3.3.0", "strip-comments": "^2.0.1", "tempy": "^0.6.0", "upath": "^1.2.0", - "workbox-background-sync": "7.4.0", - "workbox-broadcast-update": "7.4.0", - "workbox-cacheable-response": "7.4.0", - "workbox-core": "7.4.0", - "workbox-expiration": "7.4.0", - "workbox-google-analytics": "7.4.0", - "workbox-navigation-preload": "7.4.0", - "workbox-precaching": "7.4.0", - "workbox-range-requests": "7.4.0", - "workbox-recipes": "7.4.0", - "workbox-routing": "7.4.0", - "workbox-strategies": "7.4.0", - "workbox-streams": "7.4.0", - "workbox-sw": "7.4.0", - "workbox-window": "7.4.0" + "workbox-background-sync": "7.4.1", + "workbox-broadcast-update": "7.4.1", + "workbox-cacheable-response": "7.4.1", + "workbox-core": "7.4.1", + "workbox-expiration": "7.4.1", + "workbox-google-analytics": "7.4.1", + "workbox-navigation-preload": "7.4.1", + "workbox-precaching": "7.4.1", + "workbox-range-requests": "7.4.1", + "workbox-recipes": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1", + "workbox-streams": "7.4.1", + "workbox-sw": "7.4.1", + "workbox-window": "7.4.1" }, "engines": { "node": ">=20.0.0" @@ -14863,69 +14945,6 @@ "ajv": ">=8" } }, - "node_modules/workbox-build/node_modules/@rollup/plugin-babel": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", - "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.10.4", - "@rollup/pluginutils": "^3.1.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0", - "@types/babel__core": "^7.1.9", - "rollup": "^1.20.0||^2.0.0" - }, - "peerDependenciesMeta": { - "@types/babel__core": { - "optional": true - } - } - }, - "node_modules/workbox-build/node_modules/@rollup/plugin-replace": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz", - "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^3.1.0", - "magic-string": "^0.25.7" - }, - "peerDependencies": { - "rollup": "^1.20.0 || ^2.0.0" - } - }, - "node_modules/workbox-build/node_modules/@rollup/pluginutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", - "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "0.0.39", - "estree-walker": "^1.0.1", - "picomatch": "^2.2.2" - }, - "engines": { - "node": ">= 8.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0" - } - }, - "node_modules/workbox-build/node_modules/@types/estree": { - "version": "0.0.39", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", - "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", - "dev": true, - "license": "MIT" - }, "node_modules/workbox-build/node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -14943,13 +14962,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/workbox-build/node_modules/estree-walker": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", - "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", - "dev": true, - "license": "MIT" - }, "node_modules/workbox-build/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -14957,29 +14969,6 @@ "dev": true, "license": "MIT" }, - "node_modules/workbox-build/node_modules/magic-string": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", - "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "sourcemap-codec": "^1.4.8" - } - }, - "node_modules/workbox-build/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/workbox-build/node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -14993,22 +14982,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/workbox-build/node_modules/rollup": { - "version": "2.80.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", - "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", - "dev": true, - "license": "MIT", - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=10.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/workbox-build/node_modules/source-map": { "version": "0.8.0-beta.0", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", @@ -15053,140 +15026,140 @@ } }, "node_modules/workbox-cacheable-response": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz", - "integrity": "sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.1.tgz", + "integrity": "sha512-8xaFoJdDc2OjrlbbL3gEeBO1WKcMwRqwLRupgqahYXu75yXajPLuwrbXMrIGZuWYXrQwk0xDjOxZ/ujCy/oJYw==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/workbox-core": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz", - "integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz", + "integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==", "dev": true, "license": "MIT" }, "node_modules/workbox-expiration": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.0.tgz", - "integrity": "sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.1.tgz", + "integrity": "sha512-lRKUF7b+OGbeXkQk1s6MHXOa3d7Xxf7Of31W6c6hCfipfIyrtdWZ89stq21AHZMaoG7VNFoHply4Ox+rU31TWg==", "dev": true, "license": "MIT", "dependencies": { "idb": "^7.0.1", - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/workbox-google-analytics": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.0.tgz", - "integrity": "sha512-MVPXQslRF6YHkzGoFw1A4GIB8GrKym/A5+jYDUSL+AeJw4ytQGrozYdiZqUW1TPQHW8isBCBtyFJergUXyNoWQ==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.1.tgz", + "integrity": "sha512-Mks1JwLEt++ZAkF6sS1OpSh9RtAMIsiDgRpK+codiHGIPXeaUOgi4cPc3GFadUl8V5QPeypEk8Oxgl3HlwVzHw==", "dev": true, "license": "MIT", "dependencies": { - "workbox-background-sync": "7.4.0", - "workbox-core": "7.4.0", - "workbox-routing": "7.4.0", - "workbox-strategies": "7.4.0" + "workbox-background-sync": "7.4.1", + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" } }, "node_modules/workbox-navigation-preload": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.0.tgz", - "integrity": "sha512-etzftSgdQfjMcfPgbfaZCfM2QuR1P+4o8uCA2s4rf3chtKTq/Om7g/qvEOcZkG6v7JZOSOxVYQiOu6PbAZgU6w==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.1.tgz", + "integrity": "sha512-C4KVsjPcYKJOhr631AxR9XoG2rLF3QiTk5aMv36MXOjtWvm8axwNFAtKUPGsWUwLXXAMgYM1En7fsvndaXeXRQ==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/workbox-precaching": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.0.tgz", - "integrity": "sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz", + "integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.0", - "workbox-routing": "7.4.0", - "workbox-strategies": "7.4.0" + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" } }, "node_modules/workbox-range-requests": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.0.tgz", - "integrity": "sha512-3Vq854ZNuP6Y0KZOQWLaLC9FfM7ZaE+iuQl4VhADXybwzr4z/sMmnLgTeUZLq5PaDlcJBxYXQ3U91V7dwAIfvw==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.1.tgz", + "integrity": "sha512-7i2oxAUE82gHdAJBCAQ04JzNOdRPqzuOzGfoUyJpFSmeqBNYGPrAH8GPoPjUQTfp+NycwrD2H68VtuF8qxv0vQ==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/workbox-recipes": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.0.tgz", - "integrity": "sha512-kOkWvsAn4H8GvAkwfJTbwINdv4voFoiE9hbezgB1sb/0NLyTG4rE7l6LvS8lLk5QIRIto+DjXLuAuG3Vmt3cxQ==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.1.tgz", + "integrity": "sha512-gnbVfmV4/TtmQaM4x9AtuXhcdstJsep3XMVeztOrQVPT+R6+6DeBjGTCQ7fFCXm+4GEHUA5VEBTyi5+4gWGeog==", "dev": true, "license": "MIT", "dependencies": { - "workbox-cacheable-response": "7.4.0", - "workbox-core": "7.4.0", - "workbox-expiration": "7.4.0", - "workbox-precaching": "7.4.0", - "workbox-routing": "7.4.0", - "workbox-strategies": "7.4.0" + "workbox-cacheable-response": "7.4.1", + "workbox-core": "7.4.1", + "workbox-expiration": "7.4.1", + "workbox-precaching": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" } }, "node_modules/workbox-routing": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.0.tgz", - "integrity": "sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz", + "integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/workbox-strategies": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.0.tgz", - "integrity": "sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz", + "integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/workbox-streams": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.0.tgz", - "integrity": "sha512-QHPBQrey7hQbnTs5GrEVoWz7RhHJXnPT+12qqWM378orDMo5VMJLCkCM1cnCk+8Eq92lccx/VgRZ7WAzZWbSLg==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.1.tgz", + "integrity": "sha512-HWWtraKUbJknd9kgqGcpQ3G114HOPYvqs8HaJMDs2ebLNAimDkVDaWfAXE6Ybl+m8U6KsCE6pWyLYuigWmnAXw==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.0", - "workbox-routing": "7.4.0" + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1" } }, "node_modules/workbox-sw": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.0.tgz", - "integrity": "sha512-ltU+Kr3qWR6BtbdlMnCjobZKzeV1hN+S6UvDywBrwM19TTyqA03X66dzw1tEIdJvQ4lYKkBFox6IAEhoSEZ8Xw==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.1.tgz", + "integrity": "sha512-fez5f2DUlDJWTFYkCWQpY10N8gtztd849NswCbVFk0QlcSM4HT5A8x4g4ii650yem4I8tHY0R7JZahwp3ltIPw==", "dev": true, "license": "MIT" }, "node_modules/workbox-window": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.0.tgz", - "integrity": "sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.1.tgz", + "integrity": "sha512-notZDH2u8VXaqyuD7xaqIfEFi6SRM4SUSd7ewe9PDsVqADuepxX2ZMY3uvuZGxzY5ZOsGC/vD3A/3smFtJt4/A==", "dev": true, "license": "MIT", "dependencies": { "@types/trusted-types": "^2.0.2", - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/wrappy": { @@ -15212,9 +15185,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "dev": true, "license": "ISC", "optional": true, diff --git a/package.json b/package.json index cd2f476..95526be 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#master", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#sendgrid_whitelabel", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", diff --git a/src/app/UserWrapper.tsx b/src/app/UserWrapper.tsx index 5ac1f91..a625411 100644 --- a/src/app/UserWrapper.tsx +++ b/src/app/UserWrapper.tsx @@ -7,12 +7,14 @@ 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 { useMobile, useSnackbar } from 'hooks' import { initCrisp, isCrispEnabled } from '../chat/CrispChat' +import { useTeamAPI } from '../providers/pond/teamAPI' // import FirmwareLoader from './FirmwareLoader' const reducer = (state: GlobalState, action: GlobalStateAction): GlobalState => { @@ -61,6 +63,7 @@ export default function UserWrapper(props: Props) { const { token } = props; const [loading, setLoading] = useState(false) const userAPI = useUserAPI(); + const teamAPI = useTeamAPI(); const classes = useStyles(); const hasFetched = useRef(false); const [global, setGlobal] = useState(undefined) @@ -81,9 +84,22 @@ export default function UserWrapper(props: Props) { 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: [], diff --git a/src/teams/TeamSettings.tsx b/src/teams/TeamSettings.tsx index 87f47db..58f9b25 100644 --- a/src/teams/TeamSettings.tsx +++ b/src/teams/TeamSettings.tsx @@ -37,6 +37,7 @@ export default function TeamSettings(props: Props) { const [loading, setLoading] = useState(false); const [nameField, setNameField] = useState(""); const [infoField, setInfoField] = useState(""); + const [whitelabelField, setWhitelabelField] = useState(""); const [url, setUrl] = useState(""); const snackbar = useSnackbar(); const [tab, setTab] = useState(0); @@ -55,6 +56,7 @@ export default function TeamSettings(props: Props) { if (team) { setNameField(team.name()); setInfoField(team.settings.info); + setWhitelabelField(team.settings.whitelabel); setUrl(team.settings.avatar); } }, [team]); @@ -77,6 +79,7 @@ export default function TeamSettings(props: Props) { let team = pond.TeamSettings.create(); team.name = nameField; team.info = infoField; + team.whitelabel = whitelabelField; setLoading(true); teamAPI .addTeam(team) @@ -101,6 +104,7 @@ export default function TeamSettings(props: Props) { let newTeam = pond.TeamSettings.create(); newTeam.name = nameField; newTeam.info = infoField; + newTeam.whitelabel = whitelabelField; newTeam.avatar = url; if (teamKey) { teamAPI @@ -131,6 +135,7 @@ export default function TeamSettings(props: Props) { const close = () => { setNameField(""); setInfoField(""); + setWhitelabelField(""); setUrl(""); closeTeamDialogCallback(); }; @@ -194,6 +199,15 @@ export default function TeamSettings(props: Props) { style={{ margin: theme.spacing(1) }} fullWidth /> + setWhitelabelField(event?.target.value)} + variant="outlined" + style={{ margin: theme.spacing(1) }} + placeholder="e.g. mivent, adaptive-ag, intellifarms" + fullWidth + /> ); }; From ea13f97c5779c64424c8939944a1b5b9f73faf37 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Thu, 11 Jun 2026 16:48:00 -0600 Subject: [PATCH 111/146] more visual tweaks and design, fixed a coule bugs and removed some console logs --- src/bin/3dView/Scene/Bin3dView.tsx | 4 +- src/bin/BinPlayer.tsx | 23 ++-- src/bin/BinTableExpanded.tsx | 136 +++++++++------------- src/bin/bin3dVisualizer.tsx | 47 +++++--- src/bin/binSensorsDisplay.tsx | 38 +++--- src/bin/binSummary/BinSummary.tsx | 178 ++++++++++------------------- src/bin/binTableView.tsx | 120 ++++++++++++------- src/theme/theme.ts | 4 +- 8 files changed, 257 insertions(+), 293 deletions(-) diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index f8107f4..36d6f27 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -138,8 +138,8 @@ export default function Bin3dView(props: Props) { { isPlaying.current = false; }; const originalBin = useRef(null); @@ -350,8 +351,6 @@ export default function BinPlayer(props: Props) { * then builds `statusCheckpoints` for playback over [startDate, endDate]. */ const startPlayer = async () => { - console.log(startDate.toISOString()) - console.log(endDate.toISOString()) originalBin.current = cloneDeep(bin); // capture before async work begins isPlaying.current = true; setIsPlayingState(true) @@ -369,8 +368,6 @@ const startPlayer = async () => { endDate, checkpointCountFromRange(startDate, endDate) ); - console.log(responses) - console.log(checkpoints) for (let i = 0; i < checkpoints.length; i++) { if (!isPlaying.current) { @@ -387,7 +384,6 @@ const startPlayer = async () => { setBin(newBin); setCurrentCheckpointTime(checkpoints[i].time) await new Promise(res => setTimeout(res, PLAYBACK_INTERVAL)); - console.log("next step") } isPlaying.current = false; setIsPlayingState(false) @@ -455,8 +451,10 @@ const dateSelector = () => { { return ( - + Heatmap @@ -111,8 +113,11 @@ export default function bin3dVisualizer(props: Props){ value={binDisplay} sx={{ borderRadius: 1, - bgcolor: 'rgba(255,255,255,0.08)', - '& .MuiSelect-select': { py: 1.5 }, + 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' }, @@ -173,9 +178,12 @@ export default function bin3dVisualizer(props: Props){ } const controllerDisplay = () => { + if (fans && fans.length > 1 || heaters && heaters.length > 1){ + + return ( - - + + Controllers {fans && fans.map(fan => { @@ -187,15 +195,16 @@ export default function bin3dVisualizer(props: Props){ display: 'flex', justifyContent: 'space-between', alignItems: 'center', - bgcolor: 'rgba(255,255,255,0.05)', + // bgcolor: 'rgba(255,255,255,0.05)', + bgcolor: '#151E27', borderRadius: 1, px: 2, py: 1, }} > - {fan.name()} + {fan.name()} - + {isOn ? "ON" : "OFF"} - {heater.name()} + {heater.name()} - + {isOn ? "ON" : "OFF"} { return ( - + {binModeControl()} {heatmapDisplay()} @@ -397,7 +410,7 @@ export default function bin3dVisualizer(props: Props){ showMoisture={showMoisture && showLabels} // showGrain={showFill} // showHotspots={showHotspots} - xOffset={isMobile ? undefined : -150} + xOffset={isMobile ? undefined : -120} /> {isMobile ? mobileHUD() : desktopHUD()} diff --git a/src/bin/binSensorsDisplay.tsx b/src/bin/binSensorsDisplay.tsx index a2085e1..9785b5e 100644 --- a/src/bin/binSensorsDisplay.tsx +++ b/src/bin/binSensorsDisplay.tsx @@ -734,7 +734,7 @@ export default function BinSensorsDisplay(props: Props){ if(showGraphs && device){ let icon = GetComponentIcon(plenum.type(), plenum.subType(), themeType) return( - + + {tempHumidCard(plenum)} ) @@ -765,7 +765,7 @@ export default function BinSensorsDisplay(props: Props){ if(showGraphs && device){ let icon = GetComponentIcon(pressure.type(), pressure.subType(), themeType) return( - + + {pressureCard(pressure)} ) @@ -795,7 +795,7 @@ export default function BinSensorsDisplay(props: Props){ if(showGraphs && device){ let icon = GetComponentIcon(ambient.type(), ambient.subType(), themeType) return( - + + {tempHumidCard(ambient)} ) @@ -826,7 +826,7 @@ export default function BinSensorsDisplay(props: Props){ if(showGraphs && device){ let icon = GetComponentIcon(h.type(), h.subType(), themeType) return( - + + {tempHumidCard(h)} ) @@ -857,7 +857,7 @@ export default function BinSensorsDisplay(props: Props){ if(showGraphs && device){ let icon = GetComponentIcon(co2.type(), co2.subType(), themeType) return( - + + {co2Card(co2)} ) @@ -887,7 +887,7 @@ export default function BinSensorsDisplay(props: Props){ if(showGraphs && device){ let icon = GetComponentIcon(lidar.type(), lidar.subType(), themeType) return( - + + {lidarCard(lidar)} ) @@ -917,7 +917,7 @@ export default function BinSensorsDisplay(props: Props){ if(showGraphs && device){ let icon = GetComponentIcon(cable.type(), cable.subType(), themeType) return( - + + {cableCard(cable)} ) @@ -957,7 +957,7 @@ export default function BinSensorsDisplay(props: Props){ if(showGraphs && device){ let icon = GetComponentIcon(heater.type(), heater.subType(), themeType) return( - + + {controllerCard(heater)} ) @@ -987,7 +987,7 @@ export default function BinSensorsDisplay(props: Props){ if(showGraphs && device){ let icon = GetComponentIcon(fan.type(), fan.subType(), themeType) return( - + + {controllerCard(fan)} ) @@ -1020,7 +1020,7 @@ export default function BinSensorsDisplay(props: Props){ Unassigned Components {unassignedComponents.map(comp => ( - + {unassignedComponentCard(comp)} ))} diff --git a/src/bin/binSummary/BinSummary.tsx b/src/bin/binSummary/BinSummary.tsx index f868712..6d39c25 100644 --- a/src/bin/binSummary/BinSummary.tsx +++ b/src/bin/binSummary/BinSummary.tsx @@ -113,125 +113,69 @@ export default function BinSummary(props: Props){ return bin.grainInventory(user) } - const inventorySummaryMobile = () => { - return ( - - - - {/* Grain Type */} + const inventorySummaryMobile = () => ( + + {/* Header row */} + + + setOpenGrainDialog(true)}> + + - - Grain Type - - 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)", - }, - }} - > - - {bin.grainName()} - + Grain type + {bin.grainName()} - - {/* Bin Fill */} - - - Bin Fill - - - {/* Top line: current / capacity */} - - {currentFill()} / {bin.grainCapacity(user)} - - {/* Bottom line: bar/slider + percent */} - - {bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL ? ( - setOpenNewTransaction(true)} - onChange={(_, val) => { - let fillPercent = val as number - setManualFillPercent(fillPercent) - setManualBushels((fillPercent / 100) * bin.bushelCapacity()) - }} - min={0} - max={100} - sx={{ - flexGrow: 1, - color: "#4caf50", - "& .MuiSlider-thumb": { width: 16, height: 16 }, - "& .MuiSlider-rail": { backgroundColor: "rgba(255,255,255,0.1)" }, - }} - /> - ) : ( - - )} - - {bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL - ? manualFillPercent - : bin.fillPercent()}% - - - - - - {/* Last Updated */} - - - Last Updated - - - - {/* Top line: time */} - - - - {moment(bin.status.timestamp).fromNow()} - - - {/* Bottom line: live/inactive status */} - - {activity(bin.status.timestamp)} - - - - - - - ); - } + + + + Last updated + {moment(bin.status.timestamp).fromNow()} + + + + + {/* Fill row */} + + + Bin fill + + + {bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL + ? manualFillPercent : bin.fillPercent()}% + + + {currentFill().toLocaleString()} / {bin.grainCapacity(user).toLocaleString()} + + + + {bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL ? ( + + 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)" }, + }} /> + + ) : ( + + )} + + + ); const inventorySummaryDesktop = () => { return ( - + {/* Grain Type */} @@ -395,7 +339,7 @@ export default function BinSummary(props: Props){ {loading ? : - + {setBin && - + } @@ -420,7 +364,7 @@ export default function BinSummary(props: Props){ {loading ? : - + : - + } diff --git a/src/bin/binTableView.tsx b/src/bin/binTableView.tsx index 33fcf6a..0659335 100644 --- a/src/bin/binTableView.tsx +++ b/src/bin/binTableView.tsx @@ -1,5 +1,5 @@ import { Edit, GppGood, Info, Save } from "@mui/icons-material"; -import { Box, Button, darken, DialogActions, IconButton, Table, TableBody, TableCell, TableHead, TableRow, TextField, Theme, Tooltip, Typography } from "@mui/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"; @@ -22,34 +22,40 @@ const useStyles = makeStyles((theme: Theme) => ({ }, headerCellStyle: { fontWeight: 650, - backgroundColor: darken(theme.palette.background.paper, 0.2), + backgroundColor: darken(theme.palette.background.paper, 0.3), textAlign: "center", - borderTop: "1px solid black", - borderLeft: "1px solid black", - borderBottom: "1px solid black", - "&:last-child": { - borderRight: "1px solid black", - } + padding: 10 }, mobileHeaderCellStyle: { fontWeight: 650, - backgroundColor: darken(theme.palette.background.paper, 0.2), + backgroundColor: darken(theme.palette.background.paper, 0.3), textAlign: "center", - borderTop: "1px solid black", - borderLeft: "1px solid black", - borderBottom: "1px solid black", - "&:last-child": { - borderRight: "1px solid black", - }, padding: 2 }, - cellStyle: { - padding: 2, - fontWeight: 650, - fontSize: 17, + targetCellStyle: { textAlign: "center", - border: "1px solid black", - + 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" } }) ); @@ -71,6 +77,7 @@ 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) @@ -163,13 +170,13 @@ export default function BinTableView(props: Props) { return levels },[bin]) - const levelDisplay = (level: BinLevel) => { + const levelDisplay = (level: BinLevel, lastRow?: boolean) => { let tempDisplay = "--" let moistureDisplay = "--" let lvlTemp let lvlMoisture - let tempColour: string = "" - let moistureColour: string = "" + 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){ @@ -207,12 +214,12 @@ export default function BinTableView(props: Props) { lvlMoisture = avg(level.airMoistures) } if(lvlMoisture){ - moistureDisplay = lvlMoisture.toFixed(2) + "%" + moistureDisplay = lvlMoisture.toFixed(2) } return ( - {tempDisplay} - {moistureDisplay} + {tempDisplay} + {moistureDisplay} ) } @@ -301,10 +308,11 @@ export default function BinTableView(props: Props) { {expandedTableDialog()} {/* Level Table */} - Grain Table + Grain Table - - - - ); -}; + isPlaying.current = false; + setIsPlayingState(false); + setBin(originalBin.current!); + setCurrentCheckpointTime(undefined); + }; -const dateSelector = () => { - return ( - - - { - 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; - - } - }} - > - 1 Week - 2 Weeks - 4 Weeks - Custom - - - {dateSelect === 3 && ( - - )} - - ) -} + }, + '& .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; + } + }} + > + 1 Week + 2 Weeks + 4 Weeks + Custom + + + {dateSelect === 3 && ( + + )} + + ); + }; return ( @@ -500,18 +694,18 @@ const dateSelector = () => { {/* start/stop button */} {isPlayingState ? ( - + ) : ( - + )} {/* progress bar and date display */} - {currentCheckpointTime ? currentCheckpointTime.format("MMM D, YYYY h:mm A") : moment().format("MMM D, YYYY h:mm A")} + {currentCheckpointTime ? currentCheckpointTime.format("MMM D, YYYY h:mm A") : moment().format("MMM D, YYYY h:mm A")} - {startDate.format("MMM D, YYYY")} - {endDate.format("MMM D, YYYY")} + {startDate.format("MMM D, YYYY")} + {endDate.format("MMM D, YYYY")} {/* date range selector */} diff --git a/src/bin/bin3dVisualizer.tsx b/src/bin/bin3dVisualizer.tsx index 50df08e..06bc722 100644 --- a/src/bin/bin3dVisualizer.tsx +++ b/src/bin/bin3dVisualizer.tsx @@ -16,8 +16,8 @@ interface Props { bin: Bin // cables?: GrainCable[] devices: Device[] - fans?: Controller[] - heaters?: Controller[] + // fans?: Controller[] + // heaters?: Controller[] permissions: pond.Permission[] componentDevices: Map componentMap: Map @@ -25,7 +25,7 @@ interface Props { updateBinCallback?: (bin: Bin) => void } export default function bin3dVisualizer(props: Props){ - const {bin, fans, heaters, permissions, devices, componentDevices, componentMap, binPrefs, updateBinCallback} = props + const {bin, permissions, devices, componentDevices, componentMap, binPrefs, updateBinCallback} = props const theme = useTheme() const [showHeatmap, setShowHeatmap] = useState(true) // const [showHotspots, setShowHotspots] = useState(false) @@ -86,7 +86,6 @@ export default function bin3dVisualizer(props: Props){ '&.Mui-focused .MuiOutlinedInput-notchedOutline': { border: 'none' }, }} onChange={event => { - console.log("change bin mode") setBinMode(event.target.value as pond.BinMode) setOpenModeChange(true) }} @@ -159,38 +158,37 @@ export default function bin3dVisualizer(props: Props){ ) } - 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 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 (fans && fans.length > 1 || heaters && heaters.length > 1){ - - + if ((bin.status.fans && bin.status.fans.length > 0) || (bin.status.heaters && bin.status.heaters.length > 0)){ return ( Controllers - {fans && fans.map(fan => { - let isOn = controllerState(fan) + {bin.status.fans && bin.status.fans.map(fan => { + // let isOn = controllerState(fan) + return ( - {fan.name()} + {fan.name} - {isOn ? "ON" : "OFF"} + {fan.state ? "ON" : "OFF"} )})} - {heaters && heaters.map(heater => { - let isOn = controllerState(heater) + {bin.status.heaters && bin.status.heaters.map(heater => { + // let isOn = controllerState(heater) return ( - {heater.name()} + {heater.name} - {isOn ? "ON" : "OFF"} + {heater.state ? "ON" : "OFF"} @@ -382,17 +380,23 @@ export default function bin3dVisualizer(props: Props){ //filter the controls so that only components on THIS device are options for new interactions, a side note of this using the components that are in the bins status //is that it will indirectly also filter by bin as well so only components that are both on the bin and the same device as the cable will appear let filtered: Component[] = [] - if(fans){ - fans.forEach((f) => { - if(componentDevices.get(f.key()) === d.id()){ - filtered.push(f.asComponent()) + if(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(heaters){ - heaters.forEach((h) => { - if(componentDevices.get(h.key()) === d.id()){ - filtered.push(h.asComponent()) + 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) + } } }) } diff --git a/src/bin/binSummary/BinSummary.tsx b/src/bin/binSummary/BinSummary.tsx index 6d39c25..293a720 100644 --- a/src/bin/binSummary/BinSummary.tsx +++ b/src/bin/binSummary/BinSummary.tsx @@ -341,9 +341,7 @@ export default function BinSummary(props: Props){ : Date: Fri, 12 Jun 2026 14:14:12 -0600 Subject: [PATCH 115/146] 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 --- src/pages/BinV2.tsx | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/pages/BinV2.tsx b/src/pages/BinV2.tsx index 832c4a5..a0f9135 100644 --- a/src/pages/BinV2.tsx +++ b/src/pages/BinV2.tsx @@ -94,18 +94,18 @@ export default function BinV2(){ const [bin, setBin] = useState(IBin.create()); const [devices, setDevices] = useState([]); const [components, setComponents] = useState>(new Map()); - const [interactions, setInteractions] = useState([]); + // const [interactions, setInteractions] = useState([]); const [permissions, setPermissions] = useState([]); const [preferences, setPreferences] = useState>(); const [plenums, setPlenums] = useState([]); - const [ambients, setAmbients] = useState([]); + // const [ambients, setAmbients] = useState([]); const [grainCables, setGrainCables] = useState([]); - const [pressures, setPressures] = useState([]); - const [headspaceCO2, setHeadspaceCO2] = useState([]); + // const [pressures, setPressures] = useState([]); + // const [headspaceCO2, setHeadspaceCO2] = useState([]); const [heaters, setHeaters] = useState([]); const {openSnack} = useSnackbar() const [fans, setFans] = useState([]); - const [compositionNameMap, setCompositionNameMap] = useState>(new Map()); + // const [compositionNameMap, setCompositionNameMap] = useState>(new Map()); const [anchorEl, setAnchorEl] = useState(null); const [noteTab, setNoteTab] = useState(0) @@ -133,14 +133,14 @@ export default function BinV2(){ binAPI .getBinPageData(binID, user.id(), showErrors, as) .then(resp => { - if (resp.data.grainCompositionNames) { - let tempMap: Map = new Map(); - Object.keys(resp.data.grainCompositionNames).forEach(key => { - tempMap.set(key, resp.data.grainCompositionNames[key]); - }); - tempMap.set("correction", "Unknown Source"); - setCompositionNameMap(tempMap); - } + // if (resp.data.grainCompositionNames) { + // let tempMap: Map = new Map(); + // Object.keys(resp.data.grainCompositionNames).forEach(key => { + // tempMap.set(key, resp.data.grainCompositionNames[key]); + // }); + // tempMap.set("correction", "Unknown Source"); + // setCompositionNameMap(tempMap); + // } let devs: Device[] = []; let p = new Map(); @@ -171,11 +171,11 @@ export default function BinV2(){ }); setInteractionDevices(newInteractionDevices); - if (resp.data.interactions) { - setInteractions(resp.data.interactions.map((i: pond.Interaction) => Interaction.any(i))); - } else { - setInteractions([]); - } + // if (resp.data.interactions) { + // setInteractions(resp.data.interactions.map((i: pond.Interaction) => Interaction.any(i))); + // } else { + // setInteractions([]); + // } //go through the devices and if the device has an attached component include it in the device list if (resp.data.devices && resp.data.devices[0]) { resp.data.devices.forEach((dev: any) => { @@ -278,11 +278,11 @@ export default function BinV2(){ setMissedReadings(mostMissed); setPlenums(plenums); setGrainCables(grainCables); - setPressures(pressures); - setAmbients(ambients); + // setPressures(pressures); + // setAmbients(ambients); setHeaters(heaters); setFans(fans); - setHeadspaceCO2(headspaceCo2); + // setHeadspaceCO2(headspaceCo2); }, [components, preferences]); useEffect(() => { From bbc9c4ee7f02f14ba7243b34be0a4a62a0d0bca9 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Fri, 12 Jun 2026 14:43:37 -0600 Subject: [PATCH 116/146] changing some naming for the component --- package-lock.json | 2 +- src/pbHelpers/ComponentTypes/AnalogPressure.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0e54b33..1f15d16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11205,7 +11205,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#b44f5fa7183d9565e9fd9e7a9ac756114401ddfd", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#807316f6e3473e9416e2366c6081d5a93245d9d9", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/src/pbHelpers/ComponentTypes/AnalogPressure.ts b/src/pbHelpers/ComponentTypes/AnalogPressure.ts index f4d3936..7921b38 100644 --- a/src/pbHelpers/ComponentTypes/AnalogPressure.ts +++ b/src/pbHelpers/ComponentTypes/AnalogPressure.ts @@ -34,9 +34,9 @@ import { type: quack.ComponentType.COMPONENT_TYPE_PRESSURE, subtypes: [ { - key: quack.AnalogPressureSubtype.ANALOG_PRESSURE_SUBTYPE_PMC21, - value: "ANALOG_PRESSURE_SUBTYPE_PMC21", - friendlyName: "PMC 21" + key: quack.AnalogPressureSubtype.ANALOG_PRESSURE_SUBTYPE_PM1618, + value: "ANALOG_PRESSURE_SUBTYPE_PM1618", + friendlyName: "PM 1618" } as Subtype ], friendlyName: "Analog Pressure", From 1051534bb59c5a847c06db5d60a73aee41519744 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Fri, 12 Jun 2026 14:58:30 -0600 Subject: [PATCH 117/146] proto update to stagings proto --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1f15d16..37bd044 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#dev", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", @@ -11205,7 +11205,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#807316f6e3473e9416e2366c6081d5a93245d9d9", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#6d889de1a42adf590373ff932823e5e697634c6a", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/package.json b/package.json index ba69f2e..90ac71b 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#dev", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", From c4999a0ba5e7cc3de81680c9ba92abefa253b30f Mon Sep 17 00:00:00 2001 From: Carter Date: Tue, 16 Jun 2026 12:32:43 -0600 Subject: [PATCH 118/146] adding control emails to control component settings form --- package-lock.json | 4 +- package.json | 2 +- src/component/ComponentForm.tsx | 84 ++++++++++- src/component/ComponentSettings.tsx | 223 ++++++++++++++++++++++++---- 4 files changed, 275 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index 58133fb..4d78d05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#control_emails", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", @@ -11198,7 +11198,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#29d9765ce93c04ad70d795b80e9873fc2fc5f600", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#4011e9e7a75148a195e7311c41a4e6c8d6886d7b", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/package.json b/package.json index cd2f476..daa71c4 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#master", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#control_emails", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", diff --git a/src/component/ComponentForm.tsx b/src/component/ComponentForm.tsx index 17c1bf7..dbebbb5 100644 --- a/src/component/ComponentForm.tsx +++ b/src/component/ComponentForm.tsx @@ -19,9 +19,10 @@ import { Tooltip, Typography } from "@mui/material"; -import { +import { ExpandMore, - Close as CloseIcon + Close as CloseIcon, + Remove as RemoveIcon } from "@mui/icons-material"; import PeriodSelect from "common/time/PeriodSelect"; import SearchSelect, { Option } from "common/SearchSelect"; @@ -97,6 +98,7 @@ interface Props { component: Component; componentChanged: (component: Component, formValid: boolean) => void; canEdit: boolean; + hideControllerFields?: boolean; } interface ComponentData { @@ -119,7 +121,7 @@ export default function ComponentForm(props: Props) { const classes = useStyles(); const grainOptions = GrainOptions(); const [{ user }] = useGlobalState(); - const { device, component, componentChanged, canEdit } = props; + const { device, component, componentChanged, canEdit, hideControllerFields } = props; const [reportExpanded, setReportExpanded] = useState(false); const [dataUsageWarningDismissed, setDataUsageWarningDismissed] = useState(true); const [form, setForm] = useState({ @@ -137,7 +139,8 @@ export default function ComponentForm(props: Props) { sensorDistance: "0", }); const [compMode, setCompMode] = useState(); - const [useCustomGrain, setUseCustomGrain] = useState(false) + const [useCustomGrain, setUseCustomGrain] = useState(false); + const [controlEmail, setControlEmail] = useState(""); //const [numCalibrations, setNumCalibrations] = useState(0) useEffect(() => { @@ -356,6 +359,21 @@ export default function ComponentForm(props: Props) { setForm(f); }; + const addControlEmail = () => { + const trimmed = controlEmail.trim().toLowerCase(); + if (trimmed === "" || form.component.settings.controlEmails.includes(trimmed)) return; + let f = cloneDeep(form); + f.component.settings.controlEmails.push(trimmed); + setForm(f); + setControlEmail(""); + }; + + const removeControlEmail = (index: number) => { + let f = cloneDeep(form); + f.component.settings.controlEmails.splice(index, 1); + setForm(f); + }; + const toggleMeasure = (event: any) => { let f = cloneDeep(form); f.measure = event.target.checked; @@ -1341,7 +1359,7 @@ export default function ComponentForm(props: Props) { }} /> )} - {isController(component.settings.type) && ( + {isController(component.settings.type) && !hideControllerFields && ( + + + Authorized Control Emails + + + + setControlEmail(e.target.value)} + onKeyDown={e => { + if (e.key === "Enter") { + e.preventDefault(); + addControlEmail(); + } + }} + disabled={!canEdit} + /> + + + + + + {component.settings.controlEmails.map((email, i) => ( + + + {email} + + + removeControlEmail(i)} + disabled={!canEdit} + className={classes.redButton}> + + + + + ))} + )} {showDataUsageWarning && ( diff --git a/src/component/ComponentSettings.tsx b/src/component/ComponentSettings.tsx index db0e5ad..c6c981b 100644 --- a/src/component/ComponentSettings.tsx +++ b/src/component/ComponentSettings.tsx @@ -8,6 +8,7 @@ import { DialogContentText, DialogTitle, Divider, + FormControl, FormControlLabel, Grid2 as Grid, IconButton, @@ -41,8 +42,10 @@ import { GetComponentTypeOptions, getFriendlyName, getMeasurements, - isController + isController, } from "pbHelpers/ComponentType"; +import PeriodSelect from "common/time/PeriodSelect"; +import { bestUnit, milliToX } from "common/time/duration"; import { ComponentAvailabilityMap, DeviceAvailabilityMap, @@ -177,6 +180,7 @@ export default function ComponentSettings(props: Props) { const [formValid, setFormValid] = useState(false); const [excludedNodes, setExcludedNodes] = useState([]); const [nodeToExclude, setNodeToExclude] = useState(0); + const [controlEmail, setControlEmail] = useState(""); const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => { setTabVal(newValue); @@ -498,6 +502,7 @@ export default function ComponentSettings(props: Props) { component={formComponent} device={device} canEdit={canEdit} + hideControllerFields={isController(formComponent.settings.type)} componentChanged={(formComp, isValid) => { formComponent.settings = formComp.settings; setFormValid(isValid); @@ -867,6 +872,145 @@ export default function ComponentSettings(props: Props) { ); }; + const addControlEmail = () => { + const trimmed = controlEmail.trim().toLowerCase(); + if (trimmed === "" || formComponent.settings.controlEmails.includes(trimmed)) return; + let f = cloneDeep(formComponent); + f.settings.controlEmails.push(trimmed); + setFormComponent(f); + setControlEmail(""); + }; + + const removeControlEmail = (index: number) => { + let f = cloneDeep(formComponent); + f.settings.controlEmails.splice(index, 1); + setFormComponent(f); + }; + + const handleOutputModeChanged = (event: any) => { + let f = cloneDeep(formComponent); + f.settings.defaultOutputState = event.target.value; + setFormComponent(f); + }; + + const handleMinCycleTimeChanged = (ms: number) => { + let f = cloneDeep(formComponent); + f.settings.minCycleTimeMs = Number(ms); + setFormComponent(f); + }; + + const isMinCycleTimeValid = () => { + const ext = extension(formComponent.settings.type, formComponent.settings.subtype); + if (!ext.isController) return true; + let min = Math.max(0, ext.minCycleTimeMs ? ext.minCycleTimeMs : 0); + return formComponent.settings.minCycleTimeMs >= min; + }; + + const minCycleTimeDescription = () => { + const ext = extension(formComponent.settings.type, formComponent.settings.subtype); + if (!ext.isController) return ""; + const min = Math.max(0, ext.minCycleTimeMs ? ext.minCycleTimeMs : 0); + const unit = bestUnit(min); + const value = milliToX(min, unit).toString(); + return "Must be at least " + value + " " + unit; + }; + + const controlContent = () => { + return ( + + + Auto + On + Off + + + + + + Authorized Control Emails + + + + setControlEmail(e.target.value)} + onKeyDown={e => { + if (e.key === "Enter") { + e.preventDefault(); + addControlEmail(); + } + }} + disabled={!canEdit} + /> + + + + + + {formComponent.settings.controlEmails.map((email, i) => ( + + + {email} + + + removeControlEmail(i)} + disabled={!canEdit} + className={classNames(classes.redButton)}> + + + + + ))} + + ); + }; + const actions = () => { return ( @@ -1042,36 +1186,55 @@ export default function ComponentSettings(props: Props) { scroll="paper"> {title()} - {!isController(formComponent.settings.type) && ( - - - - - + {isController(formComponent.settings.type) ? ( + + + + + + + {content()} + + + {controlContent()} + + + ) : ( + + + + + + + + {content()} + + + + + {overlayGroup()} + + + + + {componentPrefs()} + + )} - - {content()} - - - - - {overlayGroup()} - - - - - {componentPrefs()} - {actions()} )} From a4ed52db1e3b68bef16bc052725326138be6c62b Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 16 Jun 2026 12:40:07 -0600 Subject: [PATCH 119/146] 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 --- src/bin/3dView/Scene/Bin3dView.tsx | 46 +++++++++++-------- .../Systems/Inventory/GrainCableFill.tsx | 15 +++--- .../Systems/Inventory/GrainFillFlat.tsx | 11 ++--- src/bin/bin3dVisualizer.tsx | 2 - src/bin/binSummary/BinSummary.tsx | 23 +++++++++- 5 files changed, 61 insertions(+), 36 deletions(-) diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index d14297d..0d15558 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -6,7 +6,7 @@ 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 { Vector3 } from "three"; import { useMemo } from "react"; import { BuildCableData, CableData } from "../Data/BuildCableData"; import { BuildNodeData, NodeData } from "../Data/BuildNodeData"; @@ -14,10 +14,10 @@ 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 { Box } from "@mui/material"; import MoistureHeatMap from "../Systems/Heatmap/MoistureHeatmap"; import { grainYBoundsFromFillPercent } from "../utils/grainFillBounds"; -import { RadiansToDegrees } from "common/TrigFunctions"; +// import { RadiansToDegrees } from "common/TrigFunctions"; // import { GrainCable } from "models/GrainCable" // Fallback dimensions used when a bin has no advanced dimensions configured. @@ -40,6 +40,10 @@ interface Props { */ 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 @@ -108,25 +112,30 @@ export default function Bin3dView(props: Props) { 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 ( ) } else if (fillPercent) { return ( ) } @@ -155,8 +164,6 @@ export default function Bin3dView(props: Props) { renderOrder={4} /> - {showGrain && grainInventory()} - } {/* note that the heatmaps internally use render order 1,2, and 3 for the three separate coloured meshes */} - {showTempHeatmap && ( - 0 + ? - )} - {showMoistureHeatmap && ( - - )} + : showMoistureHeatmap && nodeData.length > 0 + ? + : grainInventory() + } diff --git a/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx b/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx index 3cb8cd7..f7b769b 100644 --- a/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx +++ b/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx @@ -16,6 +16,7 @@ interface Props { * If undefined and no top nodes exist, nothing is rendered. */ fallbackFillPercent?: number; + colour?: string } // Tuning knobs @@ -59,13 +60,13 @@ export default function GrainCableFill(props: Props) { hopperHeight = 0, nodes, fallbackFillPercent, - grainOpacity + grainOpacity, + colour } = props; const binRadius = diameter / 2; // Slightly inset to avoid z-fighting with the shell const grainRadius = binRadius * 0.98; - const grainColour = "#fff302"; // --- Collect top nodes (non-excluded, inGrain) --- const topNodes = useMemo(() => @@ -256,7 +257,7 @@ export default function GrainCableFill(props: Props) { }} position={new Vector3(0, -(sidewallHeight / 2 + hopperHeight) + hopperFillHeight / 2, 0)} rotation={new Euler(Math.PI, 0, 0)} - colour={grainColour} + colour={colour} roughness={1} metalness={0} opacity={grainOpacity} @@ -276,7 +277,7 @@ export default function GrainCableFill(props: Props) { openEnded: false }} position={new Vector3(0, -sidewallHeight / 2 + cylinderFillHeight / 2, 0)} - colour={grainColour} + colour={colour} roughness={1} metalness={0} opacity={grainOpacity} @@ -296,7 +297,7 @@ export default function GrainCableFill(props: Props) { {surfaceGeometry && ( {/* Hopper fill */} @@ -98,7 +97,7 @@ export default function GrainFillFlat(props: Props) { }} position={hopperPosition} rotation={hopperRotation} - colour={grainColour} + colour={colour} roughness={1} metalness={0} opacity={grainOpacity} @@ -119,7 +118,7 @@ export default function GrainFillFlat(props: Props) { openEnded: false }} position={cylinderPosition} - colour={grainColour} + colour={colour} roughness={1} metalness={0} opacity={grainOpacity} diff --git a/src/bin/bin3dVisualizer.tsx b/src/bin/bin3dVisualizer.tsx index 06bc722..9bdfd0c 100644 --- a/src/bin/bin3dVisualizer.tsx +++ b/src/bin/bin3dVisualizer.tsx @@ -414,8 +414,6 @@ export default function bin3dVisualizer(props: Props){ showMoistureHeatmap={showMoistureHeatmap} showTemp={showTemp && showLabels} showMoisture={showMoisture && showLabels} - // showGrain={showFill} - // showHotspots={showHotspots} xOffset={isMobile ? undefined : -120} /> {isMobile ? mobileHUD() : desktopHUD()} diff --git a/src/bin/binSummary/BinSummary.tsx b/src/bin/binSummary/BinSummary.tsx index 293a720..dcab089 100644 --- a/src/bin/binSummary/BinSummary.tsx +++ b/src/bin/binSummary/BinSummary.tsx @@ -71,6 +71,25 @@ export default function BinSummary(props: Props){ // } // },[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" @@ -139,7 +158,7 @@ export default function BinSummary(props: Props){ {/* Fill row */} - Bin fill + Bin fill ({inventoryControl()}) {bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL @@ -210,7 +229,7 @@ export default function BinSummary(props: Props){ {/* Bin Fill */} - Bin Fill + Bin Fill ({inventoryControl()}) {/* hidden measuring span, renders off-screen */} From b378764d627e7c5e8b53e19fe94bb0b6e1684cac Mon Sep 17 00:00:00 2001 From: Carter Date: Wed, 17 Jun 2026 14:11:43 -0600 Subject: [PATCH 120/146] added some info about email control to the control tab for the component settings --- src/component/ComponentSettings.tsx | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/component/ComponentSettings.tsx b/src/component/ComponentSettings.tsx index c6c981b..e9a7983 100644 --- a/src/component/ComponentSettings.tsx +++ b/src/component/ComponentSettings.tsx @@ -953,7 +953,7 @@ export default function ComponentSettings(props: Props) { } /> - + Authorized Control Emails @@ -1007,6 +1007,28 @@ export default function ComponentSettings(props: Props) { ))} + + + Email Control + + + Authorized emails can control this component by sending an email to the + control address. Contact your administrator for the control email + address. Use one of the following as the email subject: + + +
{device.id()}:{formComponent.settings.type}-{formComponent.settings.addressType}-{formComponent.settings.address} - ON
+
{device.id()}:{formComponent.settings.type}-{formComponent.settings.addressType}-{formComponent.settings.address} - OFF
+
{device.id()}:{formComponent.settings.type}-{formComponent.settings.addressType}-{formComponent.settings.address} - AUTO
+
); }; From d9846ea02b211373b1ba45cd84837bec2537c815 Mon Sep 17 00:00:00 2001 From: Carter Date: Wed, 17 Jun 2026 15:41:26 -0600 Subject: [PATCH 121/146] firmware renders with warning styling if it wasn't found in the server --- src/device/VersionChip.tsx | 8 ++++++++ src/pbHelpers/FirmwareVersion.tsx | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/device/VersionChip.tsx b/src/device/VersionChip.tsx index 09dac0f..8826239 100644 --- a/src/device/VersionChip.tsx +++ b/src/device/VersionChip.tsx @@ -22,6 +22,14 @@ class VersionChip extends React.Component { variant="outlined" label={firmwareVersionHelper.description} icon={firmwareVersionHelper.icon} + sx={ + firmwareVersionHelper.colour + ? { + borderColor: firmwareVersionHelper.colour, + color: firmwareVersionHelper.colour + } + : undefined + } />
); diff --git a/src/pbHelpers/FirmwareVersion.tsx b/src/pbHelpers/FirmwareVersion.tsx index d7dcc8c..43ea776 100644 --- a/src/pbHelpers/FirmwareVersion.tsx +++ b/src/pbHelpers/FirmwareVersion.tsx @@ -9,6 +9,7 @@ export interface FirmwareVersionHelper { const oldFirmwareColour = "var(--status-warning)"; const currentFirmwareColour = "var(--status-ok)"; +const unknownFirmwareColour = "var(--status-warning)"; export function getFirmwareVersionHelper( version: string, @@ -17,24 +18,34 @@ export function getFirmwareVersionHelper( const firmwareIcon = ; const oldFirmwareIcon = ; const currentFirmwareIcon = ; + const unknownFirmwareIcon = ; let helper = {} as FirmwareVersionHelper; if (!version || version === "") { helper.description = "Unknown version"; helper.icon = oldFirmwareIcon; helper.tooltip = "We don't know what version your device is, it may behave unexpectedly"; + helper.colour = oldFirmwareColour; + } else if (version.startsWith("!")) { + helper.description = version; + helper.icon = unknownFirmwareIcon; + helper.tooltip = "This firmware version was reported by the device but is missing from the firmware list"; + helper.colour = unknownFirmwareColour; } else if (available !== "" && version !== available) { helper.description = version; helper.icon = oldFirmwareIcon; helper.tooltip = "A firmware upgrade is available, some features may be disabled for out-of-date devices"; + helper.colour = oldFirmwareColour; } else if (available === "") { helper.description = version; helper.icon = firmwareIcon; helper.tooltip = "Running version " + version; + helper.colour = ""; } else { helper.description = version; helper.icon = currentFirmwareIcon; helper.tooltip = "Your device is running the latest firmware"; + helper.colour = currentFirmwareColour; } return helper; } From 0984ae0f37efed9f48b21e4f60f14dd957b09a9b Mon Sep 17 00:00:00 2001 From: Carter Date: Wed, 17 Jun 2026 16:19:35 -0600 Subject: [PATCH 122/146] got rid of warning signifier on firmware print --- src/pbHelpers/FirmwareVersion.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pbHelpers/FirmwareVersion.tsx b/src/pbHelpers/FirmwareVersion.tsx index 43ea776..49472be 100644 --- a/src/pbHelpers/FirmwareVersion.tsx +++ b/src/pbHelpers/FirmwareVersion.tsx @@ -26,9 +26,10 @@ export function getFirmwareVersionHelper( helper.tooltip = "We don't know what version your device is, it may behave unexpectedly"; helper.colour = oldFirmwareColour; } else if (version.startsWith("!")) { - helper.description = version; + const cleanVersion = version.substring(1); + helper.description = cleanVersion; helper.icon = unknownFirmwareIcon; - helper.tooltip = "This firmware version was reported by the device but is missing from the firmware list"; + helper.tooltip = "Firmware " + cleanVersion + " was reported by the device but is missing from the firmware list"; helper.colour = unknownFirmwareColour; } else if (available !== "" && version !== available) { helper.description = version; From 939a53d0724cb325e55a15a8a0fd59be66f5bfba Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 23 Jun 2026 16:25:31 -0600 Subject: [PATCH 123/146] had claude look at the bin player to determine how to take into account the component history for the top nodes --- package-lock.json | 4 +- package.json | 2 +- src/bin/BinPlayer.tsx | 96 ++++++++++++++++++++++++++++- src/providers/pond/componentAPI.tsx | 39 ++++++++++++ 4 files changed, 135 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index a6c8aad..2c4d240 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#sendgrid_whitelabel", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#bin_player_inventory_changes", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", @@ -11752,7 +11752,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#9c0f668d4a56b8216dd71a44c3110a818aaf3541", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#4bd44bd7e19e7e58344c224b3413a81ece379fed", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/package.json b/package.json index 1fb2505..e86d5f5 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#sendgrid_whitelabel", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#bin_player_inventory_changes", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", diff --git a/src/bin/BinPlayer.tsx b/src/bin/BinPlayer.tsx index 88bc45c..6173596 100644 --- a/src/bin/BinPlayer.tsx +++ b/src/bin/BinPlayer.tsx @@ -90,6 +90,58 @@ function upsertReading( } } +/** + * 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; +}; + +/** + * Converts raw `ComponentHistory` entries for one cable into a sorted (ascending) array of + * `TopNodeHistoryEntry` records, keeping only entries where `grain_filled_to` is defined and + * non-zero so that we never overwrite a known top node with a "not set" value. + */ +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()); +} + +/** + * Binary-searches a sorted `TopNodeHistoryEntry[]` for the most recent entry whose timestamp + * is at or before `checkpointTime`. Returns `undefined` when no such entry exists (i.e. the + * checkpoint is before the first recorded top-node change). + */ +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; +} + /** * Produces `count` wall-clock times evenly spaced from `start` through `end` (inclusive). * These are the playback frames — not derived from measurement data. @@ -360,6 +412,8 @@ function buildStatusCheckpoints( 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, start: Moment, end: Moment, count: number = CHECKPOINT_COUNT @@ -416,7 +470,19 @@ function buildStatusCheckpoints( const bucketReadings = cableBucket.get(key) ?? {}; const merged = mergeCarriedReadings(carriedByCable.get(key)!, bucketReadings); carriedByCable.set(key, merged); - return buildGrainCableFromReadings(template, merged, time); + 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 @@ -529,6 +595,18 @@ export default function BinPlayer(props: Props) { 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; + console.log(deviceID) + 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; @@ -541,12 +619,23 @@ export default function BinPlayer(props: Props) { return componentAPI.sampleUnitMeasurements(deviceID, heater.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true); }); - const [cableResponses, fanResponses, heaterResponses] = await Promise.all([ + const [cableResponses, fanResponses, heaterResponses, cableHistoryResponses] = await Promise.all([ Promise.all(cableSampleRequests), Promise.all(fanSampleRequests), Promise.all(heaterSampleRequests), + Promise.all(cableHistoryRequests), ]); + console.log(cableHistoryResponses) + + // Build a per-cable sorted top-node history map for use during checkpoint construction. + const topNodeHistories = new Map(); + bin.status.grainCables.forEach((cable, i) => { + const historyResp = cableHistoryResponses[i]; + const entries = buildTopNodeHistory(historyResp?.data?.history ?? []); + topNodeHistories.set(cable.key, entries); + }); + const checkpoints = buildStatusCheckpoints( bin.status.grainCables, bin.status.fans, @@ -554,6 +643,7 @@ export default function BinPlayer(props: Props) { cableResponses.map(r => r.data), fanResponses.map(r => r.data), heaterResponses.map(r => r.data), + topNodeHistories, startDate, endDate, checkpointCountFromRange(startDate, endDate) @@ -715,4 +805,4 @@ export default function BinPlayer(props: Props) { ); -} +} \ No newline at end of file diff --git a/src/providers/pond/componentAPI.tsx b/src/providers/pond/componentAPI.tsx index 3e2207e..a224952 100644 --- a/src/providers/pond/componentAPI.tsx +++ b/src/providers/pond/componentAPI.tsx @@ -62,6 +62,14 @@ export interface IComponentAPIContext { keys?: string[], types?: string[] ) => Promise>; + listHistoryBetween: ( + deviceId: string | number, + componentKey: string, + start: string, + end: string, + keys?: string[], + types?: string[] + ) => Promise>; // Old measuremnt structure functions, no longer used in codebase // listMeasurements: ( // device: number, @@ -391,6 +399,36 @@ export default function ComponentProvider(props: PropsWithChildren) { }) }; + const listHistoryBetween = ( + deviceId: string | number, + componentKey: string, + start: string, + end: string, + keys?: string[], + types?: string[] + ) => { + let url = pondURL( + "/devices/" + + deviceId + + "/components/" + + componentKey + + "/historyBetween?start=" + + start + + "&end=" + + end + + (keys ? "&keys=" + keys : "&keys=" + [deviceId.toString()]) + + (types ? "&types=" + types : "&types=" + ["device"]) + ) + return new Promise>((resolve,reject) => { + get(url).then(resp => { + resp.data = pond.ListComponentHistoryBetweenResponse.fromObject(resp.data) + return resolve(resp) + }).catch(err => { + return reject(err) + }) + }) + }; + // const listMeasurements = ( // device: number, // component: string, @@ -596,6 +634,7 @@ export default function ComponentProvider(props: PropsWithChildren) { listForObject: listComponentsForObject, listComponentCardData: listComponentCardData, listHistory, + listHistoryBetween, //listMeasurements: listMeasurements, //sampleMeasurements: sampleMeasurements, sampleUnitMeasurements, From 270df1a11876b581c5fb9476ebba6891b06f2eec Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 23 Jun 2026 16:40:09 -0600 Subject: [PATCH 124/146] fixed unit from V to mV for analog pressure --- package-lock.json | 2 +- src/pbHelpers/MeasurementDescriber.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 37bd044..4d9535e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11205,7 +11205,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#6d889de1a42adf590373ff932823e5e697634c6a", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#42f72fa7fb838b7771e6c847faaa192e93f42da7", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/src/pbHelpers/MeasurementDescriber.ts b/src/pbHelpers/MeasurementDescriber.ts index 947c545..ccc50a7 100644 --- a/src/pbHelpers/MeasurementDescriber.ts +++ b/src/pbHelpers/MeasurementDescriber.ts @@ -174,10 +174,9 @@ export class MeasurementDescriber { this.details.path = "power.inputVoltageTimes10"; this.details.decimals = 1; } - if (componentType === quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE) { + if (componentType === quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE || componentType === quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE) { this.details.unit = "mV"; this.details.graph = GraphType.MULTILINE; - this.details.unit = "mV"; this.details.decimals = 0 // this.details.nodeDetails = { // colours: ["white", "black", "red", "blue"], From 728d07a07e625300d98b15a0d045bdeb940c1778 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 23 Jun 2026 16:42:30 -0600 Subject: [PATCH 125/146] proto update --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 55cca88..7df4b5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11752,7 +11752,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#42f72fa7fb838b7771e6c847faaa192e93f42da7", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#8c5091affb9c1ef4e80c675072435b745b07e612", "dependencies": { "protobufjs": "^6.8.8" } From f73eb0fcbd80d6c37bf92aabea6a867072ad76fb Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 23 Jun 2026 16:44:42 -0600 Subject: [PATCH 126/146] proto update --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7df4b5b..afc0c3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#sendgrid_whitelabel", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", @@ -11752,7 +11752,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#8c5091affb9c1ef4e80c675072435b745b07e612", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#42f72fa7fb838b7771e6c847faaa192e93f42da7", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/package.json b/package.json index 1fb2505..3a76c43 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#sendgrid_whitelabel", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", From 4ea0d53c94482786bf6671efc369c1378bbab180 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 24 Jun 2026 12:38:29 -0600 Subject: [PATCH 127/146] adding analog pressure to have the cable id show up for analog cables --- src/component/ComponentSettings.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/component/ComponentSettings.tsx b/src/component/ComponentSettings.tsx index db0e5ad..a133de3 100644 --- a/src/component/ComponentSettings.tsx +++ b/src/component/ComponentSettings.tsx @@ -507,11 +507,12 @@ export default function ComponentSettings(props: Props) { ); }; - const isCableComponent = () => { + const hasCableID = () => { return (formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE || formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE || formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_PRESSURE_CABLE || + formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE || (formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_CAPACITOR_CABLE && formComponent.subType() === quack.CapacitorCableSubtype.CAPACITOR_CABLE_SUBTYPE_FROG)) @@ -577,7 +578,8 @@ export default function ComponentSettings(props: Props) { )} {supportsExpansion() && setExpLine()} - {(isCableComponent() && !supportsExpansion())&& setComponentAddrType()} + + {(hasCableID() && !supportsExpansion())&& setComponentAddrType()} ) : activeStep === 1 ? ( Date: Wed, 24 Jun 2026 13:13:14 -0600 Subject: [PATCH 128/146] opening interaction websocket to detect accepted pending changes --- src/interactions/InteractionsOverview.tsx | 45 ++++++++++++++++++++--- src/pages/Device.tsx | 43 +++++++++++++++++++++- 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/src/interactions/InteractionsOverview.tsx b/src/interactions/InteractionsOverview.tsx index 600c88a..db73675 100644 --- a/src/interactions/InteractionsOverview.tsx +++ b/src/interactions/InteractionsOverview.tsx @@ -4,6 +4,7 @@ import { CardActions, CardContent, CardHeader, + CircularProgress, darken, Grid2 as Grid, IconButton, @@ -14,7 +15,7 @@ import { Tooltip, Typography } from "@mui/material"; -import { Settings } from "@mui/icons-material"; +import { CheckCircleOutline, Settings } from "@mui/icons-material"; import { useInteractionsAPI, usePrevious, useSnackbar } from "hooks"; import { cloneDeep } from "lodash"; import { Component, Device, Interaction } from "models"; @@ -80,6 +81,7 @@ export default function InteractionsOverview(props: Props) { const [interactions, setInteractions] = useState(props.interactions); const [dirtyInteractions, setDirtyInteractions] = useState>(new Map()); const [mappedComponents, setMappedComponents] = useState>(new Map()); + const [acceptedInteractions, setAcceptedInteractions] = useState>(new Set()); const [renderToggle, setRenderToggle] = useState(false); const [selectedInteraction, setSelectedInteraction] = useState( undefined @@ -95,6 +97,20 @@ export default function InteractionsOverview(props: Props) { } if (props.interactions !== prevInteractions) { + if (prevInteractions) { + setAcceptedInteractions((prevAccepted) => { + const updatedAccepted = new Set(prevAccepted); + props.interactions.forEach((interaction: Interaction) => { + const previous = prevInteractions.find(prevInteraction => prevInteraction.key() === interaction.key()); + if (!interaction.status.synced) { + updatedAccepted.delete(interaction.key()); + } else if (previous && !previous.status.synced) { + updatedAccepted.add(interaction.key()); + } + }); + return updatedAccepted; + }); + } setInteractions(cloneDeep(props.interactions)); } }, [components, prevComponents, props.interactions, prevInteractions]); @@ -226,10 +242,16 @@ export default function InteractionsOverview(props: Props) { let key = interaction.key(); let source = mappedComponents.get(componentIDToString(interaction.settings.source)); let sink = mappedComponents.get(componentIDToString(interaction.settings.sink)); + const isPending = !interaction.status.synced && Boolean(interaction.status.lastUpdate); + const isAccepted = interaction.status.synced && acceptedInteractions.has(interaction.key()); let statusText = - !interaction.status.synced && interaction.status.lastUpdate - ? "Pending " + - moment(interaction.status.lastUpdate && interaction.status.lastUpdate).fromNow() + !interaction.status.synced + ? interaction.status.lastUpdate + ? "Pending " + + moment(interaction.status.lastUpdate && interaction.status.lastUpdate).fromNow() + : "" + : acceptedInteractions.has(interaction.key()) + ? "Pending change accepted" : ""; let schedule = pond.InteractionSchedule.create( interaction.settings.schedule !== null ? interaction.settings.schedule : undefined @@ -272,7 +294,20 @@ export default function InteractionsOverview(props: Props) { className={classes.header} titleTypographyProps={{ variant: "subtitle2" }} title={interactionResultText(interaction, sink)} - subheader={statusText} + subheader={ + statusText ? ( + + {isPending && } + {isAccepted && ( + + )} + + {statusText} + + + ) : ""} subheaderTypographyProps={{ variant: "caption" }} action={action} /> diff --git a/src/pages/Device.tsx b/src/pages/Device.tsx index 6c9535d..96b06b6 100644 --- a/src/pages/Device.tsx +++ b/src/pages/Device.tsx @@ -282,6 +282,45 @@ export default function DevicePage() { enabled: !!deviceID, }); + // --- Real-time interaction updates --- + // Streams interaction changes for this device, including status changes + // when the device accepts pending interaction updates. + useWebSocket<{ key: string; interaction: Interaction } | null>({ + path: `/v1/live/devices/${deviceID}/interactions`, + parse: (e) => { + try { + const raw = JSON.parse(e.data); + const interaction = Interaction.any(raw); + if (!interaction?.settings) { + console.warn("[ws] received interaction without settings:", raw); + return null; + } + return { key: interaction.key(), interaction }; + } catch (err) { + console.warn("[ws] failed to parse interaction:", err); + return null; + } + }, + onMessage: (data) => { + if (!data) return; + const { key, interaction } = data; + setInteractions((prev) => { + const index = prev.findIndex((existing) => existing.key() === key); + if (index < 0) { + return [...prev, interaction]; + } + const updated = [...prev]; + updated[index] = interaction; + return updated; + }); + }, + keys: liveContextKeys, + types: liveContextTypes, + as: liveAs, + token, + enabled: !!deviceID, + }); + const loadPortScan = () => { deviceAPI.listFoundComponents(parseInt(deviceID)) .then(resp => { @@ -395,7 +434,7 @@ export default function DevicePage() { interactions={filteredInteractions} permissions={permissions} deviceComponentPreferences={prefsMap.get(c.key())} - key={i} + key={c.key()} refreshCallback={(updatedComponent?: Component) => updatedComponent ? handleComponentChanged(updatedComponent) : loadDevice() } @@ -412,7 +451,7 @@ export default function DevicePage() { interactions={filteredInteractions} permissions={permissions} deviceComponentPreferences={prefsMap.get(c.key())} - key={i} + key={c.key()} refreshCallback={(updatedComponent?: Component) => updatedComponent ? handleComponentChanged(updatedComponent) : loadDevice() } From 879a5ae1f08bc9b4109c50dabdfe189a22e9daab Mon Sep 17 00:00:00 2001 From: Carter Date: Wed, 24 Jun 2026 14:16:54 -0600 Subject: [PATCH 129/146] fixed pending state logic --- src/interactions/InteractionsOverview.tsx | 60 ++++++++++++++--------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/src/interactions/InteractionsOverview.tsx b/src/interactions/InteractionsOverview.tsx index db73675..2619e97 100644 --- a/src/interactions/InteractionsOverview.tsx +++ b/src/interactions/InteractionsOverview.tsx @@ -70,6 +70,11 @@ interface Props { refreshCallback: () => void; } +interface PendingInteraction { + since: string; + accepted: boolean; +} + export default function InteractionsOverview(props: Props) { const classes = useStyles(); const [{as, user}] = useGlobalState(); @@ -81,7 +86,7 @@ export default function InteractionsOverview(props: Props) { const [interactions, setInteractions] = useState(props.interactions); const [dirtyInteractions, setDirtyInteractions] = useState>(new Map()); const [mappedComponents, setMappedComponents] = useState>(new Map()); - const [acceptedInteractions, setAcceptedInteractions] = useState>(new Set()); + const [pendingInteractions, setPendingInteractions] = useState>(new Map()); const [renderToggle, setRenderToggle] = useState(false); const [selectedInteraction, setSelectedInteraction] = useState( undefined @@ -97,20 +102,23 @@ export default function InteractionsOverview(props: Props) { } if (props.interactions !== prevInteractions) { - if (prevInteractions) { - setAcceptedInteractions((prevAccepted) => { - const updatedAccepted = new Set(prevAccepted); - props.interactions.forEach((interaction: Interaction) => { - const previous = prevInteractions.find(prevInteraction => prevInteraction.key() === interaction.key()); - if (!interaction.status.synced) { - updatedAccepted.delete(interaction.key()); - } else if (previous && !previous.status.synced) { - updatedAccepted.add(interaction.key()); + setPendingInteractions((prevPending) => { + const updatedPending = new Map(prevPending); + props.interactions.forEach((interaction: Interaction) => { + const existing = updatedPending.get(interaction.key()); + if (!interaction.status.synced) { + if (!existing || existing.accepted) { + updatedPending.set(interaction.key(), { + since: interaction.status.lastUpdate || moment().toISOString(), + accepted: false + }); } - }); - return updatedAccepted; + } else if (existing && !existing.accepted) { + updatedPending.set(interaction.key(), { ...existing, accepted: true }); + } }); - } + return updatedPending; + }); setInteractions(cloneDeep(props.interactions)); } }, [components, prevComponents, props.interactions, prevInteractions]); @@ -217,6 +225,14 @@ export default function InteractionsOverview(props: Props) { index: number, settings: pond.IInteractionSettings ) => { + const interactionKey = settings.key ?? ""; + setPendingInteractions((prevPending) => { + const updatedPending = new Map(prevPending); + if (interactionKey) { + updatedPending.set(interactionKey, { since: moment().toISOString(), accepted: false }); + } + return updatedPending; + }); interactionsAPI .updateInteraction(Number(deviceID), settings, as) .then((_response: any) => { @@ -224,7 +240,6 @@ export default function InteractionsOverview(props: Props) { updatedDirtyInteractions.set(index, false); setDirtyInteractions(updatedDirtyInteractions); success("Successfully updated the interaction for " + component.name()); - refreshCallback(); }) .catch((_err: any) => { error("Error occurred while updating the interaction for " + component.name()); @@ -242,16 +257,13 @@ export default function InteractionsOverview(props: Props) { let key = interaction.key(); let source = mappedComponents.get(componentIDToString(interaction.settings.source)); let sink = mappedComponents.get(componentIDToString(interaction.settings.sink)); - const isPending = !interaction.status.synced && Boolean(interaction.status.lastUpdate); - const isAccepted = interaction.status.synced && acceptedInteractions.has(interaction.key()); - let statusText = - !interaction.status.synced - ? interaction.status.lastUpdate - ? "Pending " + - moment(interaction.status.lastUpdate && interaction.status.lastUpdate).fromNow() - : "" - : acceptedInteractions.has(interaction.key()) - ? "Pending change accepted" + const pending = pendingInteractions.get(interaction.key()); + const isAccepted = Boolean(pending?.accepted); + const isPending = Boolean(pending && !pending.accepted); + let statusText = isAccepted + ? "Pending change accepted" + : isPending + ? "Pending " + moment(pending.since).fromNow() : ""; let schedule = pond.InteractionSchedule.create( interaction.settings.schedule !== null ? interaction.settings.schedule : undefined From b9a13c578115c23fe2558a7b04b15e472bf98329 Mon Sep 17 00:00:00 2001 From: Carter Date: Wed, 24 Jun 2026 14:40:04 -0600 Subject: [PATCH 130/146] fixed pending change race condition --- src/interactions/InteractionsOverview.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/interactions/InteractionsOverview.tsx b/src/interactions/InteractionsOverview.tsx index 2619e97..b579660 100644 --- a/src/interactions/InteractionsOverview.tsx +++ b/src/interactions/InteractionsOverview.tsx @@ -72,6 +72,7 @@ interface Props { interface PendingInteraction { since: string; + seenUnsynced: boolean; accepted: boolean; } @@ -110,10 +111,13 @@ export default function InteractionsOverview(props: Props) { if (!existing || existing.accepted) { updatedPending.set(interaction.key(), { since: interaction.status.lastUpdate || moment().toISOString(), + seenUnsynced: true, accepted: false }); + } else if (!existing.seenUnsynced) { + updatedPending.set(interaction.key(), { ...existing, seenUnsynced: true }); } - } else if (existing && !existing.accepted) { + } else if (existing && existing.seenUnsynced && !existing.accepted) { updatedPending.set(interaction.key(), { ...existing, accepted: true }); } }); @@ -229,7 +233,7 @@ export default function InteractionsOverview(props: Props) { setPendingInteractions((prevPending) => { const updatedPending = new Map(prevPending); if (interactionKey) { - updatedPending.set(interactionKey, { since: moment().toISOString(), accepted: false }); + updatedPending.set(interactionKey, { since: moment().toISOString(), seenUnsynced: false, accepted: false }); } return updatedPending; }); From e8cc5099150384a2838f517af98b72d93adffdbd Mon Sep 17 00:00:00 2001 From: Carter Date: Thu, 25 Jun 2026 12:15:49 -0600 Subject: [PATCH 131/146] less restrictive on receiving websockets --- src/interactions/InteractionsOverview.tsx | 85 ++++++++++++++--------- src/pages/Device.tsx | 19 +++++ 2 files changed, 70 insertions(+), 34 deletions(-) diff --git a/src/interactions/InteractionsOverview.tsx b/src/interactions/InteractionsOverview.tsx index b579660..450c44f 100644 --- a/src/interactions/InteractionsOverview.tsx +++ b/src/interactions/InteractionsOverview.tsx @@ -70,12 +70,6 @@ interface Props { refreshCallback: () => void; } -interface PendingInteraction { - since: string; - seenUnsynced: boolean; - accepted: boolean; -} - export default function InteractionsOverview(props: Props) { const classes = useStyles(); const [{as, user}] = useGlobalState(); @@ -87,12 +81,21 @@ export default function InteractionsOverview(props: Props) { const [interactions, setInteractions] = useState(props.interactions); const [dirtyInteractions, setDirtyInteractions] = useState>(new Map()); const [mappedComponents, setMappedComponents] = useState>(new Map()); - const [pendingInteractions, setPendingInteractions] = useState>(new Map()); + const [acceptedInteractions, setAcceptedInteractions] = useState>(new Set()); const [renderToggle, setRenderToggle] = useState(false); const [selectedInteraction, setSelectedInteraction] = useState( undefined ); + useEffect(() => { + console.log("[interactions:mount] initial interactions:", props.interactions.map(i => ({ + key: i.key(), + synced: i.status.synced, + lastUpdate: i.status.lastUpdate, + lastSynced: i.status.lastSynced, + }))); + }, []); + useEffect(() => { if (components !== prevComponents) { let initMappedComponents: Map = new Map(); @@ -103,25 +106,30 @@ export default function InteractionsOverview(props: Props) { } if (props.interactions !== prevInteractions) { - setPendingInteractions((prevPending) => { - const updatedPending = new Map(prevPending); + console.log("[interactions:effect] props.interactions changed, prevInteractions was", prevInteractions ? "defined" : "undefined"); + props.interactions.forEach((interaction: Interaction) => { + const key = interaction.key(); + const prevInteraction = prevInteractions?.find(p => p.key() === key); + console.log("[interactions:effect]", key, { + synced: interaction.status.synced, + lastUpdate: interaction.status.lastUpdate, + prevSynced: prevInteraction?.status.synced, + prevLastUpdate: prevInteraction?.status.lastUpdate, + }); + }); + setAcceptedInteractions(prev => { + const updated = new Set(prev); props.interactions.forEach((interaction: Interaction) => { - const existing = updatedPending.get(interaction.key()); + const key = interaction.key(); + const prevInteraction = prevInteractions?.find(p => p.key() === key); if (!interaction.status.synced) { - if (!existing || existing.accepted) { - updatedPending.set(interaction.key(), { - since: interaction.status.lastUpdate || moment().toISOString(), - seenUnsynced: true, - accepted: false - }); - } else if (!existing.seenUnsynced) { - updatedPending.set(interaction.key(), { ...existing, seenUnsynced: true }); - } - } else if (existing && existing.seenUnsynced && !existing.accepted) { - updatedPending.set(interaction.key(), { ...existing, accepted: true }); + updated.delete(key); + } else if (prevInteraction && !prevInteraction.status.synced) { + console.log("[interactions:effect] ACCEPTED", key); + updated.add(key); } }); - return updatedPending; + return updated; }); setInteractions(cloneDeep(props.interactions)); } @@ -229,20 +237,30 @@ export default function InteractionsOverview(props: Props) { index: number, settings: pond.IInteractionSettings ) => { - const interactionKey = settings.key ?? ""; - setPendingInteractions((prevPending) => { - const updatedPending = new Map(prevPending); - if (interactionKey) { - updatedPending.set(interactionKey, { since: moment().toISOString(), seenUnsynced: false, accepted: false }); - } - return updatedPending; - }); interactionsAPI .updateInteraction(Number(deviceID), settings, as) .then((_response: any) => { + console.log("[interactions:submit] API success, setting synced=false for index", index); let updatedDirtyInteractions = cloneDeep(dirtyInteractions); updatedDirtyInteractions.set(index, false); setDirtyInteractions(updatedDirtyInteractions); + setInteractions(prev => { + const updated = cloneDeep(prev); + if (updated[index]) { + updated[index].status.synced = false; + updated[index].status.lastUpdate = moment().toISOString(); + console.log("[interactions:submit] local state updated for", updated[index].key()); + } + return updated; + }); + const interactionKey = settings.key ?? ""; + if (interactionKey) { + setAcceptedInteractions(prev => { + const updated = new Set(prev); + updated.delete(interactionKey); + return updated; + }); + } success("Successfully updated the interaction for " + component.name()); }) .catch((_err: any) => { @@ -261,13 +279,12 @@ export default function InteractionsOverview(props: Props) { let key = interaction.key(); let source = mappedComponents.get(componentIDToString(interaction.settings.source)); let sink = mappedComponents.get(componentIDToString(interaction.settings.sink)); - const pending = pendingInteractions.get(interaction.key()); - const isAccepted = Boolean(pending?.accepted); - const isPending = Boolean(pending && !pending.accepted); + const isAccepted = acceptedInteractions.has(interaction.key()); + const isPending = !interaction.status.synced; let statusText = isAccepted ? "Pending change accepted" : isPending - ? "Pending " + moment(pending.since).fromNow() + ? "Pending " + moment(interaction.status.lastUpdate).fromNow() : ""; let schedule = pond.InteractionSchedule.create( interaction.settings.schedule !== null ? interaction.settings.schedule : undefined diff --git a/src/pages/Device.tsx b/src/pages/Device.tsx index 96b06b6..733d621 100644 --- a/src/pages/Device.tsx +++ b/src/pages/Device.tsx @@ -304,11 +304,30 @@ export default function DevicePage() { onMessage: (data) => { if (!data) return; const { key, interaction } = data; + console.log("[ws:interaction] received", key, { + synced: interaction.status.synced, + lastUpdate: interaction.status.lastUpdate, + lastSynced: interaction.status.lastSynced, + }); setInteractions((prev) => { const index = prev.findIndex((existing) => existing.key() === key); if (index < 0) { return [...prev, interaction]; } + const existing = prev[index]; + const existingLastUpdate = getTimestampMillis(existing.status.lastUpdate); + const incomingLastUpdate = getTimestampMillis(interaction.status.lastUpdate); + const incomingLastSynced = getTimestampMillis(interaction.status.lastSynced); + if (existingLastUpdate !== undefined && incomingLastUpdate !== undefined && + existingLastUpdate > incomingLastUpdate) { + return prev; + } + if (!existing.status.synced && interaction.status.synced && + incomingLastUpdate !== undefined && + existingLastUpdate === incomingLastUpdate && + (incomingLastSynced === undefined || incomingLastSynced < incomingLastUpdate)) { + return prev; + } const updated = [...prev]; updated[index] = interaction; return updated; From ffd5e0a76902bc8d4813a31740377289fc0f5587 Mon Sep 17 00:00:00 2001 From: Carter Date: Thu, 25 Jun 2026 12:33:14 -0600 Subject: [PATCH 132/146] preventing form updates when settings dialog is open --- src/component/ComponentSettings.tsx | 3 ++- src/device/DeviceSettings.tsx | 6 ++++-- src/interactions/InteractionSettings.tsx | 9 +++++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/component/ComponentSettings.tsx b/src/component/ComponentSettings.tsx index db0e5ad..c703393 100644 --- a/src/component/ComponentSettings.tsx +++ b/src/component/ComponentSettings.tsx @@ -213,7 +213,8 @@ export default function ComponentSettings(props: Props) { }, [props.component, componentTypeOptions]); useEffect(() => { - if (props.component !== prevComponent || (!isDialogOpen && prevIsDialogOpen)) { + if (isDialogOpen && prevIsDialogOpen) return; + if (props.component !== prevComponent || isDialogOpen !== prevIsDialogOpen) { init(); } }, [props.component, prevComponent, init, isDialogOpen, prevIsDialogOpen]); diff --git a/src/device/DeviceSettings.tsx b/src/device/DeviceSettings.tsx index a9277f5..26ca591 100644 --- a/src/device/DeviceSettings.tsx +++ b/src/device/DeviceSettings.tsx @@ -95,6 +95,7 @@ export default function DeviceSettings(props: Props) { const deviceAPI = useDeviceAPI(); const { device, isDialogOpen, closeDialogCallback, canEdit, refreshCallback, components } = props; const prevDevice = usePrevious(device); + const prevIsDialogOpen = usePrevious(isDialogOpen); const [deviceForm, setDeviceForm] = useState(deviceFromForm(Device.clone(device))); const [isRemoveDeviceDialogOpen, setIsRemoveDeviceDialogOpen] = useState(false); const [sleeps, setSleeps] = useState(device.settings.sleepDurationS > 0); @@ -110,7 +111,8 @@ export default function DeviceSettings(props: Props) { const [existingMutation, setExistingMutation] = useState(); useEffect(() => { - if (prevDevice !== device) { + if (isDialogOpen && prevIsDialogOpen) return; + if (prevDevice !== device || isDialogOpen !== prevIsDialogOpen) { setDeviceForm(deviceFromForm(Device.clone(props.device))); if (device.settings.extensionComponents[0]) { setCompExtOne(device.settings.extensionComponents[0]); @@ -130,7 +132,7 @@ export default function DeviceSettings(props: Props) { setComponentsByDevice(cbd); } } - }, [device, prevDevice, props.device, components]); + }, [device, prevDevice, props.device, components, isDialogOpen, prevIsDialogOpen]); const close = () => { closeDialogCallback(); diff --git a/src/interactions/InteractionSettings.tsx b/src/interactions/InteractionSettings.tsx index 4f2a2d3..c98742f 100644 --- a/src/interactions/InteractionSettings.tsx +++ b/src/interactions/InteractionSettings.tsx @@ -133,6 +133,7 @@ export default function InteractionSettings(props: Props) { const prevInitialInteraction = usePrevious(initialInteraction); const prevComponents = usePrevious(components); const prevInitialComponent = usePrevious(initialComponent); + const prevIsDialogOpen = usePrevious(isDialogOpen); const classes = useStyles(); const [{ user, as }] = useGlobalState(); const interactionsAPI = useInteractionsAPI(); @@ -232,10 +233,12 @@ export default function InteractionSettings(props: Props) { if (user && user.settings.timezone) { setTimezone(user.settings.timezone); } + if (isDialogOpen && prevIsDialogOpen) return; if ( prevInitialInteraction !== initialInteraction || (prevComponents && prevComponents.length !== components.length) || - getComponentIDString(prevInitialComponent) !== getComponentIDString(initialComponent) + getComponentIDString(prevInitialComponent) !== getComponentIDString(initialComponent) || + isDialogOpen !== prevIsDialogOpen ) { setDefaultState(); } @@ -247,7 +250,9 @@ export default function InteractionSettings(props: Props) { prevInitialComponent, prevInitialInteraction, setDefaultState, - user + user, + isDialogOpen, + prevIsDialogOpen ]); const getAvailableSinks = () => { From 7666fbf9b65137187c4add4a3d9010d827b880ae Mon Sep 17 00:00:00 2001 From: csawatzky Date: Fri, 26 Jun 2026 15:25:18 -0600 Subject: [PATCH 133/146] update to the bin player to set the bin bushels in the status using the measurements from the bin measurements --- src/bin/BinPlayer.tsx | 94 +++++++++++++++++++++++++++++++++------- src/bin/binTableView.tsx | 2 +- 2 files changed, 79 insertions(+), 17 deletions(-) diff --git a/src/bin/BinPlayer.tsx b/src/bin/BinPlayer.tsx index 6173596..364d5af 100644 --- a/src/bin/BinPlayer.tsx +++ b/src/bin/BinPlayer.tsx @@ -8,7 +8,7 @@ import { Bin } from "models"; import moment, { Moment } from "moment"; import { pond } from "protobuf-ts/pond"; import { quack } from "protobuf-ts/quack"; -import { useComponentAPI } from "providers"; +import { useBinAPI, useComponentAPI } from "providers"; import React, { useEffect, useRef, useState } from "react"; const CHECKPOINT_COUNT = 10; @@ -16,6 +16,7 @@ const CHECKPOINT_COUNT = 10; interface StatusCheckpoint { time: Moment; timeString: string; + statusBushels: number; cables: pond.GrainCable[]; fans: pond.BinFan[]; heaters: pond.BinHeater[]; @@ -99,11 +100,6 @@ type TopNodeHistoryEntry = { topNode: number; }; -/** - * Converts raw `ComponentHistory` entries for one cable into a sorted (ascending) array of - * `TopNodeHistoryEntry` records, keeping only entries where `grain_filled_to` is defined and - * non-zero so that we never overwrite a known top node with a "not set" value. - */ function buildTopNodeHistory( history: pond.ComponentHistory[] ): TopNodeHistoryEntry[] { @@ -116,11 +112,6 @@ function buildTopNodeHistory( .sort((a, b) => a.timestamp.valueOf() - b.timestamp.valueOf()); } -/** - * Binary-searches a sorted `TopNodeHistoryEntry[]` for the most recent entry whose timestamp - * is at or before `checkpointTime`. Returns `undefined` when no such entry exists (i.e. the - * checkpoint is before the first recorded top-node change). - */ function topNodeAtTime( history: TopNodeHistoryEntry[], checkpointTime: Moment @@ -142,6 +133,61 @@ function topNodeAtTime( 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. @@ -414,6 +460,10 @@ function buildStatusCheckpoints( heaterMeasurementResponses: pond.SampleUnitMeasurementsResponse[], /** Sorted top-node history per cable key. Pass an empty Map when not needed. */ topNodeHistories: Map, + /** 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 @@ -503,7 +553,12 @@ function buildStatusCheckpoints( return buildBinHeaterFromState(template, merged); }); - return { time, cables, fans, heaters, timeString: time.toISOString() }; + // 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 => @@ -537,6 +592,7 @@ export default function BinPlayer(props: Props) { const { bin, componentDevices, setBin } = props; const isMobile = useMobile(); const componentAPI = useComponentAPI(); + const binAPI = useBinAPI(); const [dateSelect, setDateSelect] = useState(0); const [startDate, setStartDate] = useState(moment().subtract(1, 'week')); const [endDate, setEndDate] = useState(moment); @@ -598,7 +654,6 @@ export default function BinPlayer(props: Props) { // 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; - console.log(deviceID) return componentAPI.listHistoryBetween( deviceID, cable.key, @@ -619,15 +674,16 @@ export default function BinPlayer(props: Props) { return componentAPI.sampleUnitMeasurements(deviceID, heater.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true); }); - const [cableResponses, fanResponses, heaterResponses, cableHistoryResponses] = await Promise.all([ + 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, ]); - console.log(cableHistoryResponses) - // Build a per-cable sorted top-node history map for use during checkpoint construction. const topNodeHistories = new Map(); bin.status.grainCables.forEach((cable, i) => { @@ -636,6 +692,9 @@ export default function BinPlayer(props: Props) { 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, @@ -644,6 +703,8 @@ export default function BinPlayer(props: Props) { fanResponses.map(r => r.data), heaterResponses.map(r => r.data), topNodeHistories, + bushelHistory, + bin.status.grainBushels, startDate, endDate, checkpointCountFromRange(startDate, endDate) @@ -664,6 +725,7 @@ export default function BinPlayer(props: Props) { 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); diff --git a/src/bin/binTableView.tsx b/src/bin/binTableView.tsx index 0659335..e11132e 100644 --- a/src/bin/binTableView.tsx +++ b/src/bin/binTableView.tsx @@ -312,7 +312,7 @@ export default function BinTableView(props: Props) { @@ -696,9 +690,7 @@ export default function DeviceSettings(props: Props) { setNewMutationDialog(false); }} onSubmit={newMutation => { - let lm = linearMutations; - lm.push(newMutation); - setLinearMutations(lm); + setLinearMutations([...linearMutations, newMutation]); }} /> diff --git a/src/interactions/InteractionSettings.tsx b/src/interactions/InteractionSettings.tsx index c98742f..f1f3d8c 100644 --- a/src/interactions/InteractionSettings.tsx +++ b/src/interactions/InteractionSettings.tsx @@ -32,7 +32,6 @@ import moment from "moment-timezone"; import { componentIDToString, emptyComponentId, - getComponentIDString, sameComponentID, stringToComponentId } from "pbHelpers/Component"; @@ -130,9 +129,6 @@ export default function InteractionSettings(props: Props) { } = props; const theme = useTheme(); const { success, error } = useSnackbar(); - const prevInitialInteraction = usePrevious(initialInteraction); - const prevComponents = usePrevious(components); - const prevInitialComponent = usePrevious(initialComponent); const prevIsDialogOpen = usePrevious(isDialogOpen); const classes = useStyles(); const [{ user, as }] = useGlobalState(); @@ -167,7 +163,7 @@ export default function InteractionSettings(props: Props) { const setDefaultState = useCallback(() => { let interaction = getDefaultInteraction(); if (initialInteraction && mode === "update") { - interaction = initialInteraction; + interaction = Interaction.clone(initialInteraction); if (interaction.settings.subtype === 0 || interaction.settings.subtype === 1) { setSubtypeDropdown(interaction.settings.subtype); } else { @@ -223,7 +219,7 @@ export default function InteractionSettings(props: Props) { setDutyCycle(interactionResult.dutyCycle.toString()); setMappedComponents(mappedComponents); setInteraction(interaction); - }, [components, initialComponent, initialConditions, initialInteraction, mode, /*sensor*/]); + }, [components, initialComponent, initialConditions, initialInteraction, mode, sensor, user]); const availableSources = () => { return components.filter(component => isSource(component.settings.type)); @@ -233,27 +229,12 @@ export default function InteractionSettings(props: Props) { if (user && user.settings.timezone) { setTimezone(user.settings.timezone); } - if (isDialogOpen && prevIsDialogOpen) return; - if ( - prevInitialInteraction !== initialInteraction || - (prevComponents && prevComponents.length !== components.length) || - getComponentIDString(prevInitialComponent) !== getComponentIDString(initialComponent) || - isDialogOpen !== prevIsDialogOpen - ) { - setDefaultState(); - } - }, [ - components.length, - initialComponent, - initialInteraction, - prevComponents, - prevInitialComponent, - prevInitialInteraction, - setDefaultState, - user, - isDialogOpen, - prevIsDialogOpen - ]); + }, [user]); + + useEffect(() => { + if (!isDialogOpen || prevIsDialogOpen) return; + setDefaultState(); + }, [isDialogOpen, prevIsDialogOpen, setDefaultState]); const getAvailableSinks = () => { let type = or(interaction.settings.result, pond.InteractionResult.create()).type; From 696fbb3593b490de086c20f054757fc881417e16 Mon Sep 17 00:00:00 2001 From: Carter Date: Tue, 21 Jul 2026 11:59:48 -0600 Subject: [PATCH 146/146] wrapping scrollIntoView in {} to prevent it from returning void --- src/chat/ChatOutput.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/chat/ChatOutput.tsx b/src/chat/ChatOutput.tsx index 198690b..765809e 100644 --- a/src/chat/ChatOutput.tsx +++ b/src/chat/ChatOutput.tsx @@ -24,11 +24,11 @@ export default function ChatOutput(props: Props) { const ScrollToBottom = () => { const elementRef = useRef(null); - useEffect(() => + useEffect(() => { elementRef?.current?.scrollIntoView({ block: "end" - }) - ); + }); + }); let elem =
; return elem; };