From 505cb8e3aa586cc510c7080efc9b4b549c610ce9 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Fri, 28 Mar 2025 15:54:11 -0600 Subject: [PATCH] imported the gate and terminal related files to prep for the aviation map --- src/charts/SingleSetAreaChart.tsx | 131 +++++++ src/common/DeviceLinkDrawer.tsx | 323 ++++++++++++++++ src/ducting/DuctDescriber.tsx | 105 ++++++ src/gate/AddGateFab.tsx | 57 +++ src/gate/GateActions.tsx | 203 ++++++++++ src/gate/GateDevice.tsx | 413 +++++++++++++++++++++ src/gate/GateDeviceInteraction.tsx | 255 +++++++++++++ src/gate/GateFlowGraph.tsx | 437 ++++++++++++++++++++++ src/gate/GateGraphs.tsx | 357 ++++++++++++++++++ src/gate/GateList.tsx | 143 +++++++ src/gate/GateSVG.tsx | 374 +++++++++++++++++++ src/gate/GateSettings.tsx | 574 +++++++++++++++++++++++++++++ src/models/Gate.ts | 99 +++++ src/models/Terminal.ts | 59 +++ src/models/index.ts | 1 + src/navigation/Router.tsx | 25 ++ src/navigation/SideNavigator.tsx | 19 +- src/pages/Bin.tsx | 6 +- src/pages/Gate.tsx | 476 ++++++++++++++++++++++++ src/pages/Terminals.tsx | 509 +++++++++++++++++++++++++ src/providers/index.ts | 2 +- src/providers/pond/gateAPI.tsx | 54 ++- src/providers/pond/pond.tsx | 8 +- src/providers/pond/terminalAPI.tsx | 155 ++++++++ src/terminal/TerminalSettings.tsx | 92 +++++ 25 files changed, 4868 insertions(+), 9 deletions(-) create mode 100644 src/charts/SingleSetAreaChart.tsx create mode 100644 src/common/DeviceLinkDrawer.tsx create mode 100644 src/ducting/DuctDescriber.tsx create mode 100644 src/gate/AddGateFab.tsx create mode 100644 src/gate/GateActions.tsx create mode 100644 src/gate/GateDevice.tsx create mode 100644 src/gate/GateDeviceInteraction.tsx create mode 100644 src/gate/GateFlowGraph.tsx create mode 100644 src/gate/GateGraphs.tsx create mode 100644 src/gate/GateList.tsx create mode 100644 src/gate/GateSVG.tsx create mode 100644 src/gate/GateSettings.tsx create mode 100644 src/models/Gate.ts create mode 100644 src/models/Terminal.ts create mode 100644 src/pages/Gate.tsx create mode 100644 src/pages/Terminals.tsx create mode 100644 src/providers/pond/terminalAPI.tsx create mode 100644 src/terminal/TerminalSettings.tsx diff --git a/src/charts/SingleSetAreaChart.tsx b/src/charts/SingleSetAreaChart.tsx new file mode 100644 index 0000000..99dc52a --- /dev/null +++ b/src/charts/SingleSetAreaChart.tsx @@ -0,0 +1,131 @@ +import { useTheme } from "@mui/material"; +import moment from "moment"; +import React, { useEffect, useState } from "react"; +import { + Area, + AreaChart, + ReferenceArea, + ReferenceLine, + ResponsiveContainer, + Tooltip, + TooltipProps, + XAxis, + YAxis +} from "recharts"; +import MaterialChartTooltip from "./MaterialChartTooltip"; + +export interface SSAreaDataPoint { + timestamp: number; + value: number; +} + +interface Props { + data: SSAreaDataPoint[]; + maxRef?: number; + minRef?: number; + newXDomain?: number[] | string[]; + multiGraphZoom?: (domain: number[] | string[]) => void; +} + +export default function SingleSetAreaChart(props: Props) { + const { data, maxRef, minRef, newXDomain, multiGraphZoom } = props; + const [xDomain, setXDomain] = useState(["dataMin", "dataMax"]); + const [refLeft, setRefLeft] = useState(); + const [refRight, setRefRight] = useState(); + const theme = useTheme(); + const now = moment(); + + useEffect(() => { + if (newXDomain) { + setXDomain(newXDomain); + } + }, [newXDomain]); + + const zoom = () => { + let newDomain: number[] | string[] = ["dataMin", "dataMax"]; + if (refLeft && refRight && refLeft !== refRight) { + refLeft < refRight ? (newDomain = [refLeft, refRight]) : (newDomain = [refRight, refLeft]); + setRefLeft(undefined); + setRefRight(undefined); + if (multiGraphZoom) { + multiGraphZoom(newDomain); + } else { + setXDomain(newDomain); + } + } + }; + + return ( + + + { + if (e) { + setRefLeft(e.activeLabel); + } + }} + onMouseMove={(e: any) => { + if (e) { + setRefRight(e.activeLabel); + } + }} + onMouseUp={() => { + setRefLeft(undefined); + setRefRight(undefined); + zoom(); + }}> + moment(timestamp).format("lll")} + content={(props: TooltipProps) => ( + `${value}`} /> + )} + /> + { + let t = moment(timestamp); + return now.isSame(t, "day") ? t.format("LT") : t.format("MMM DD"); + }} + scale="time" + type="number" + tick={{ fill: theme.palette.text.primary }} + stroke={theme.palette.divider} + interval="preserveStartEnd" + /> + + + + + {refLeft && refRight ? ( + + ) : null} + + + + ); +} diff --git a/src/common/DeviceLinkDrawer.tsx b/src/common/DeviceLinkDrawer.tsx new file mode 100644 index 0000000..b337791 --- /dev/null +++ b/src/common/DeviceLinkDrawer.tsx @@ -0,0 +1,323 @@ +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Checkbox, + CircularProgress, + Drawer, + List, + ListItem, + ListItemText, + Tab, + Tabs, + TextField +} from "@mui/material"; +import { ExpandMore } from "@mui/icons-material"; +import SearchBar from "common/SearchBar"; +import { useDeviceAPI, useMobile } from "hooks"; +import { Component, Device } from "models"; +import { pond } from "protobuf-ts/pond"; +import React, { useEffect, useState } from "react"; +import { makeStyles } from "@mui/styles"; + +interface TabPanelProps { + children?: React.ReactNode; + index: any; + value: any; +} + +function TabPanelMine(props: TabPanelProps) { + const { children, value, index, ...other } = props; + + return ( + + ); +} + +const useStyles = makeStyles(() => ({ + drawerPaperDesktop: { + height: "100%", + width: "40%" + }, + drawerPaperMobile: { + height: "60%", + width: "100%" + } +})); +interface Props { + open: boolean; + deviceTags?: string[]; + close: (reload?: boolean) => void; + linkedDevices: Map; + updateLinkedDevices: (dev: pond.ComprehensiveDevice, linked: boolean) => void; + linkedComponents?: Component[]; + updateLinkedComponents?: ( + deviceID: string | number, + componen: Component, + linked: boolean + ) => void; + //all of these need to be sent in for the device preference selector to work + devicePrefMap?: Map; //map using the device id as the key and its pref enums value as the value + prefOptions?: JSX.Element[]; + devicePrefChanged?: (device: Device, newPref: number) => void; +} + +export default function DeviceLinkDrawer(props: Props) { + const { + open, + close, + linkedDevices, + linkedComponents, + updateLinkedDevices, + updateLinkedComponents, + deviceTags, + devicePrefMap, + prefOptions, + devicePrefChanged + } = props; + const [value, setValue] = useState(0); + const deviceAPI = useDeviceAPI(); + const classes = useStyles(); + const isMobile = useMobile(); + const [deviceList, setDeviceList] = useState>( + new Map() + ); + const [searchVal, setSearchVal] = useState(""); + const [searchedDevices, setSearchedDevices] = useState>( + new Map() + ); + const [accordionController, setAccordionController] = useState([]); + const [loadingDevices, setLoadingDevices] = useState(false); + + const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => { + setValue(newValue); + }; + + useEffect(() => { + if (loadingDevices) return; + setLoadingDevices(true); + deviceAPI + .list( + 500, + 0, + "asc", + "key", + deviceTags?.toString(), + undefined, + undefined, + undefined, + undefined, + true + ) + .then(resp => { + let devMap = new Map(); + resp.data.comprehensiveDevices.forEach(compDev => { + if (compDev.device?.settings?.deviceId) { + devMap.set(compDev.device?.settings?.deviceId.toString(), compDev); + } + }); + setDeviceList(devMap); + setSearchedDevices(devMap); + }) + .catch(err => {}) + .finally(() => { + setLoadingDevices(false); + }); + //eslint-disable-next-line react-hooks/exhaustive-deps + }, [deviceAPI]); + + useEffect(() => { + let searchedDevices: Map = new Map(); + if (searchVal !== "") { + deviceList.forEach(dev => { + if (dev.device?.settings?.name) { + if ( + dev.device?.settings?.name.toLowerCase().includes(searchVal.toLowerCase()) || + dev.device?.settings?.deviceId.toString().includes(searchVal) + ) { + searchedDevices.set(dev.device.settings.deviceId.toString(), dev); + } + } else { + if (dev.device?.settings?.deviceId.toString().includes(searchVal)) { + searchedDevices.set(dev.device.settings.deviceId.toString(), dev); + } + } + }); + setSearchedDevices(searchedDevices); + } else { + setSearchedDevices(deviceList); + } + }, [searchVal, deviceList]); + + //sets the length of the array of booleans to control opening each of the accordions + useEffect(() => { + let accordionControls: boolean[] = []; + linkedDevices.forEach(() => { + accordionControls.push(false); + }); + deviceList.forEach(dev => { + if ( + dev.device?.settings?.deviceId && + !linkedDevices.get(dev.device?.settings?.deviceId.toString()) + ) { + accordionControls.push(false); + } + }); + setAccordionController(accordionControls); + }, [linkedDevices, deviceList]); + + const closeDrawer = () => { + close(); + }; + + const devicesTab = () => { + return ( + + Select Devices to link + + { + setSearchVal(val); + }} + /> + {Array.from(searchedDevices.values()).map(dev => { + let defCheck = linkedDevices.has(dev.device?.settings?.deviceId.toString() ?? ""); + return ( + + + { + if (dev.device?.settings?.deviceId) { + updateLinkedDevices(dev, checked); + } + }} + edge="end" + defaultChecked={defCheck} + inputProps={{ "aria-labelledby": dev.device?.settings?.name }} + /> + + ); + })} + + + ); + }; + + const linkedDevicesTab = () => { + let linkOptions: pond.ComprehensiveDevice[] = []; + Array.from(linkedDevices.keys()).forEach(key => { + let dev = deviceList.get(key); + let linkedDev = linkedDevices.get(key); + if (dev) { + linkOptions.push(dev); + } else if (linkedDev) { + linkOptions.push(linkedDev); + } + }); + + return ( + + From the linked Devices select which components to use + {linkOptions.map((op, i) => { + let device = Device.any(op.device); + let pref = 0; + if (devicePrefMap) { + pref = devicePrefMap.get(device.id()) ?? 0; + } + return ( + { + let accordionControls = accordionController; + accordionControls[i] = expanded; + setAccordionController([...accordionControls]); + }}> + }>{device.name()} + + + {op.components.map(comp => { + let component = Component.any(comp); + return ( + + + { + if (component.key() && device.id() && updateLinkedComponents) { + updateLinkedComponents(device.id(), component, checked); + } + }} + edge="end" + checked={ + linkedComponents && + linkedComponents.some(el => el.key() === component.key()) + } + inputProps={{ "aria-labelledby": component.name() }} + /> + + ); + })} + + {devicePrefMap && prefOptions && devicePrefChanged && ( + { + pref = parseInt(e.target.value); + devicePrefChanged(device, pref); + }} + fullWidth> + {prefOptions} + + )} + + + ); + })} + + ); + }; + + return ( + + {open && ( + + + + {linkedComponents && } + + + {loadingDevices ? ( + + + + ) : ( + devicesTab() + )} + + {linkedComponents && ( + + {linkedDevicesTab()} + + )} + + )} + + ); +} diff --git a/src/ducting/DuctDescriber.tsx b/src/ducting/DuctDescriber.tsx new file mode 100644 index 0000000..7dec6ed --- /dev/null +++ b/src/ducting/DuctDescriber.tsx @@ -0,0 +1,105 @@ +import { pond } from "protobuf-ts/pond"; +import { Option } from "common/SearchSelect"; +export interface InsulationGrade { + grade: string; + thermalResistance: number; +} +export interface DuctExtension { + name: string; + frictionFactor: number; + thermalConductivity: number; + thermalResistance: number; +} + +const defaultDuct: DuctExtension = { + name: "", + frictionFactor: 0.0032, + thermalResistance: 9.343, + thermalConductivity: 0.036 +}; + +export const DuctExtensions: Map = new Map([ + [pond.DuctType.DUCT_TYPE_INVALID, defaultDuct], + [pond.DuctType.DUCT_TYPE_NONE, defaultDuct], + [ + pond.DuctType.DUCT_TYPE_LAYFLAT, + { + name: "AvroDuct Layflat", + frictionFactor: 0.0032, + thermalResistance: 9.343, + thermalConductivity: 0.036 + } + ], + [ + pond.DuctType.DUCT_TYPE_SPIRAL, + { + name: "AvroDuct Spiral", + frictionFactor: 0.0065, + thermalResistance: 9.343, + thermalConductivity: 0.036 + } + ], + [ + pond.DuctType.DUCT_TYPE_AVROLITE, + { + name: "Avrolite", + frictionFactor: 0.0032, + thermalResistance: 10.132, + thermalConductivity: 0.036 + } + ], + [ + pond.DuctType.DUCT_TYPE_AVROTUBE, + { + name: "AvroTube", + frictionFactor: 0.0032, + thermalResistance: 9.343, + thermalConductivity: 0.036 + } + ] +]); + +export default function DuctDescriber(type: pond.DuctType): DuctExtension { + let describer = DuctExtensions.get(type); + //console.log(describer) + if (describer?.name === "None") { + //console.trace() + } + return describer ? describer : defaultDuct; +} + +export const ToDuctOption = (ductType: pond.DuctType): Option => { + let duct = DuctDescriber(ductType); + return { + value: ductType, + label: duct.name + } as Option; +}; + +export function DuctOptions(): Option[] { + let options: Option[] = []; + Object.values(pond.DuctType).forEach(ductType => { + if ( + typeof ductType !== "string" && + ductType !== pond.DuctType.DUCT_TYPE_INVALID && + ductType !== pond.DuctType.DUCT_TYPE_NONE + ) { + options.push(ToDuctOption(ductType)); + } + }); + return options; +} + +export function FindDuctType(tRes: number, tCond: number, friction: number): pond.DuctType { + let ductType = pond.DuctType.DUCT_TYPE_NONE; + DuctExtensions.forEach((duct, type) => { + if ( + tRes === duct.thermalResistance && + tCond === duct.thermalConductivity && + friction === duct.frictionFactor + ) { + ductType = type; + } + }); + return ductType; +} diff --git a/src/gate/AddGateFab.tsx b/src/gate/AddGateFab.tsx new file mode 100644 index 0000000..ec2b02c --- /dev/null +++ b/src/gate/AddGateFab.tsx @@ -0,0 +1,57 @@ +import { Fab, Theme } from "@mui/material"; +import Icon from "assets/products/Aviation/AddPlaneIconBlack.png"; +import { ImgIcon } from "common/ImgIcon"; +import { useMobile } from "hooks"; +import { makeStyles } from "@mui/styles"; + + +interface Props { + onClick: () => void; + pulse: boolean; +} + +const useStyles = makeStyles((theme: Theme) => ({ + "@keyframes pulsate": { + to: { + boxShadow: "0 0 0 16px" + theme.palette.primary.main + "00" + } + }, + fab: { + 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) + } + }, + pulse: { + boxShadow: "0 0 0 0 " + theme.palette.primary.main + "75", + animation: "$pulsate 1.75s infinite cubic-bezier(0.66, 0.33, 0, 1)" + } +})); + +export default function AddFab(props: Props) { + const { onClick, pulse } = props; + const classes = useStyles(); + const isMobile = useMobile(); + + const pulseString = pulse ? classes.pulse : ""; + const classString = classes.fab + " " + pulseString; + + return ( + + + + ); +} diff --git a/src/gate/GateActions.tsx b/src/gate/GateActions.tsx new file mode 100644 index 0000000..f434abb --- /dev/null +++ b/src/gate/GateActions.tsx @@ -0,0 +1,203 @@ +import { + IconButton, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Tooltip +} from "@mui/material"; +import SettingsIcon from "@mui/icons-material/Settings"; +import MoreIcon from "@mui/icons-material/MoreVert"; +import React, { useState } from "react"; +import GateSettings from "./GateSettings"; +import { Gate } from "models/Gate"; +import ObjectUsersIcon from "@mui/icons-material/AccountCircle"; +import ObjectTeamsIcon from "@mui/icons-material/SupervisedUserCircle"; +import ObjectUsers from "user/ObjectUsers"; +import { pond } from "protobuf-ts/pond"; +import { Scope } from "models"; +import ObjectTeams from "teams/ObjectTeams"; +import RemoveSelfFromObject from "user/RemoveSelfFromObject"; +import ShareObject from "user/ShareObject"; +import { blue } from "@mui/material/colors"; +import RemoveSelfIcon from "@mui/icons-material/ExitToApp"; +import { Share } from "@mui/icons-material"; +import { makeStyles } from "@mui/styles"; + +const useStyles = makeStyles(() => ({ + shareIcon: { + color: blue["500"], + "&:hover": { + color: blue["600"] + } + }, + removeIcon: { + color: "var(--status-alert)" + }, + red: { + color: "var(--status-alert)" + }, + blueIcon: { + color: blue["500"], + "&:hover": { + color: blue["600"] + } + } +})); +interface OpenState { + users: boolean; + teams: boolean; + settings: boolean; + removeSelf: boolean; + share: boolean; +} + +interface Props { + gate: Gate; + refreshCallback: () => void; + permissions: pond.Permission[]; +} + +export default function GateActions(props: Props) { + const classes = useStyles(); + const { gate, refreshCallback, permissions } = props; + const [anchorEl, setAnchorEl] = React.useState(null); + const [openState, setOpenState] = useState({ + users: false, + teams: false, + settings: false, + removeSelf: false, + share: false + }); + + const groupMenu = () => { + return ( + setAnchorEl(null)} + keepMounted + disableAutoFocusItem> + {permissions.includes(pond.Permission.PERMISSION_SHARE) && ( + { + setOpenState({ ...openState, share: true }); + setAnchorEl(null); + }} + dense> + + + + + + )} + {permissions.includes(pond.Permission.PERMISSION_USERS) && ( + { + setOpenState({ ...openState, users: true }); + setAnchorEl(null); + }}> + + + + + + )} + {permissions.includes(pond.Permission.PERMISSION_USERS) && ( + { + setOpenState({ ...openState, teams: true }); + setAnchorEl(null); + }}> + + + + + + )} + { + setOpenState({ ...openState, removeSelf: true }); + setAnchorEl(null); + }}> + + + + + + + ); + }; + + const dialogs = () => { + const key = gate.key; + const label = gate.name; + return ( + + { + if (newGate) { + } + setOpenState({ ...openState, settings: false }); + }} + /> + setOpenState({ ...openState, share: false })} + /> + setOpenState({ ...openState, users: false })} + refreshCallback={refreshCallback} + /> + setOpenState({ ...openState, teams: false })} + refreshCallback={refreshCallback} + /> + { + setOpenState({ ...openState, removeSelf: false }); + }} + /> + + ); + }; + + return ( + + + setOpenState({ ...openState, settings: true })}> + + + + ) => setAnchorEl(event.currentTarget)}> + + + {dialogs()} + {groupMenu()} + + ); +} diff --git a/src/gate/GateDevice.tsx b/src/gate/GateDevice.tsx new file mode 100644 index 0000000..3018a56 --- /dev/null +++ b/src/gate/GateDevice.tsx @@ -0,0 +1,413 @@ +import { + Box, + Button, + Card, + Grid2 as Grid, + MenuItem, + Select, + ToggleButton, + ToggleButtonGroup, + Typography +} from "@mui/material"; +import { Component, Device } from "models"; +import { Gate } from "models/Gate"; +import { pond } from "protobuf-ts/pond"; +import { useGlobalState, useGateAPI } from "providers"; +import { useEffect, useState } from "react"; +import { useMobile, useSnackbar } from "hooks"; +import { UnitMeasurement } from "models/UnitMeasurement"; +import { quack } from "protobuf-ts/quack"; +import GateDeviceInteraction from "./GateDeviceInteraction"; +import GateSVG from "./GateSVG"; +import GateGraphs from "./GateGraphs"; +//import { ToggleButton, ToggleButtonGroup } from "@material-ui/lab"; +import { getThemeType } from "theme"; + +interface Props { + gate: Gate; + comprehensiveDevice: pond.ComprehensiveDevice; + linkedCompList: Component[]; + drawerView?: boolean; +} + +export default function GateDevice(props: Props) { + const { gate, comprehensiveDevice, linkedCompList, drawerView } = props; + const [device, setDevice] = useState(Device.create()); + const [componentOptions, setComponentOptions] = useState>( + new Map() + ); + const gateAPI = useGateAPI(); + const [tempKey, setTempKey] = useState(""); + const [pressureKey, setPressureKey] = useState(""); + const [ambientKey, setAmbientKey] = useState(""); + const { openSnack } = useSnackbar(); + const [{ user }] = useGlobalState(); + const [interactionDialog, setInteractionDialog] = useState(false); + const [densityTemp, setDensityTemp] = useState(0); + const isMobile = useMobile(); + const [pcaState, setPCAState] = useState(false); + const [pcaFanOn, setPCAFanOn] = useState(false); + const [detail, setDetail] = useState<"sensors" | "analytics">("analytics"); + const [lastAmbient, setLastAmbient] = useState(0); + const [lastTemps, setLastTemps] = useState({ t1: 0, t2: 0 }); + const [lastPressures, setLastPressures] = useState({ p1: 0, p2: 0 }); + + // 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); + + useEffect(() => { + if (comprehensiveDevice.device) { + setDevice(Device.any(comprehensiveDevice.device)); + } + if (comprehensiveDevice.components) { + let components: Map = new Map(); + setAmbientKey(""); + setTempKey(""); + setPressureKey(""); + comprehensiveDevice.components.forEach(comp => { + let c = Component.any(comp); + if (linkedCompList.some(el => el.key() === c.key())) { + components.set(c.key(), c); + //determine the positioned components using the gates preferences for it components + switch (gate.preferences[c.key()]) { + case pond.GateComponentType.GATE_COMPONENT_TYPE_AMBIENT: + setAmbientKey(c.key()); + c.status.measurement.forEach(um => { + let measurement = UnitMeasurement.any(um, user); + if (measurement.type === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) { + if (measurement.values.length > 1 && measurement.values[0].values.length > 0) { + setDensityTemp(measurement.values[0].values[0]); + } + } + }); + break; + case pond.GateComponentType.GATE_COMPONENT_TYPE_TEMP: + setTempKey(c.key()); + break; + case pond.GateComponentType.GATE_COMPONENT_TYPE_PRESSURE: + setPressureKey(c.key()); + if ( + c.status.measurement[0] && + c.status.measurement[0].values[0] && + c.status.measurement[0].values[0].values[1] + ) { + setPCAFanOn(c.status.measurement[0].values[0].values[1] > 250); //apx 1 iwg + } + break; + default: + // type is unknown, do nothing + break; + } + } + }); + setComponentOptions(components); + } + }, [comprehensiveDevice, gate, linkedCompList, user]); + + const gateComponentUpdate = (componentKey: string, gateCompType: pond.GateComponentType) => { + if (componentKey !== "") { + gateAPI + .updatePrefs( + gate.key, + "component", + device.id() + ":" + componentKey, + gateCompType, + [gate.key], + ["gate"] + ) + .then(resp => { + openSnack("Component Prefence Updated"); + }); + } + }; + + useEffect(() => { + let tempComponent = componentOptions.get(tempKey); + let t1 = 0; + let t2 = 0; + if (tempComponent) { + tempComponent.status.measurement.forEach(um => { + let measurement = UnitMeasurement.any(um, user); + if ( + measurement.type === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE && + measurement.values.length > 0 + ) { + let nodeVals = measurement.values[0].values; + if (nodeVals.length > 1) { + t1 = nodeVals[0]; //uses the first node + t2 = nodeVals[1]; //uses second node node + } + } + }); + } + setLastTemps({ t1, t2 }); + }, [componentOptions, tempKey, user]); + + useEffect(() => { + let ambientComponent = componentOptions.get(ambientKey); + let temp = 0; + if (ambientComponent) { + ambientComponent.status.measurement.forEach(um => { + let measurement = UnitMeasurement.any(um, user); + if (measurement.type === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) { + if (measurement.values.length > 0 && measurement.values[0].values.length > 0) { + temp = measurement.values[0].values[0]; + } + } + }); + } + setLastAmbient(temp); + }, [componentOptions, ambientKey, user]); + + useEffect(() => { + let pressureComponent = componentOptions.get(pressureKey); + let p1 = 0; + let p2 = 0; + if (pressureComponent) { + pressureComponent.status.measurement.forEach(um => { + let measurement = UnitMeasurement.any(um, user); + if ( + measurement.type === quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE && + measurement.values.length > 0 + ) { + let nodeVals = measurement.values[0].values; + if (nodeVals.length > 1) { + p1 = nodeVals[0]; + p2 = nodeVals[1]; + } + } + }); + } + setLastPressures({ p1, p2 }); + }, [componentOptions, pressureKey, user]); + + const ambientSelector = () => { + return ( + + ); + }; + + const tempSelector = () => { + return ( + + ); + }; + + const pressureSelector = () => { + return ( + + ); + }; + + return ( + + + + {device.name()} + + setDetail("analytics")} + value={"analytics"} + aria-label="Analysis Graphs"> + Analysis + + setDetail("sensors")} + value={"sensors"} + aria-label="Sensor Graphs"> + Sensors + + + + + + + + { + setInteractionDialog(false); + }} + /> + + + { + setPCAState(state); + }} + ambient={ambientKey} + pressure={pressureKey} + device={device.id()} + /> + + + ); +} diff --git a/src/gate/GateDeviceInteraction.tsx b/src/gate/GateDeviceInteraction.tsx new file mode 100644 index 0000000..2b170b7 --- /dev/null +++ b/src/gate/GateDeviceInteraction.tsx @@ -0,0 +1,255 @@ +import { Button, DialogActions, DialogContent, DialogTitle, Typography } from "@mui/material"; +import ResponsiveDialog from "common/ResponsiveDialog"; +import { Component, Device } from "models"; +import { Gate } from "models/Gate"; +import moment from "moment"; +import { pond, quack } from "protobuf-ts/pond"; +import { useGlobalState, useInteractionsAPI, useSnackbar } from "providers"; +import React, { useEffect, useState } from "react"; + +interface Props { + open: boolean; + close: (canceled: boolean) => void; + gate: Gate; + compDevice: pond.ComprehensiveDevice; + densityTemp: number; +} + +//map that the temp is the key and the air density is the value +const densityMap = new Map([ + [35, 1.15], + [30, 1.16], + [25, 1.18], + [20, 1.2], + [15, 1.23], + [10, 1.25], + [5, 1.27], + [0, 1.29], + [-5, 1.32], + [-10, 1.34], + [-15, 1.37], + [-20, 1.39], + [-25, 1.42] +]); + +//this entire component will only be used for manual setting of each device on the gate +//once we have numbers for presets it may never be used again +export default function GateDeviceInteraction(props: Props) { + const { open, close, gate, compDevice, densityTemp } = props; + const [{ user }] = useGlobalState(); + const [lowDelta, setLowDelta] = useState(0); + const [highDelta, setHighDelta] = useState(0); + const [greenComponent, setGreenComponent] = useState(); + const [redComponent, setRedComponent] = useState(); + const [pressureComponent, setPressureComponent] = useState(); + const interactionsAPI = useInteractionsAPI(); + const [adding, setAdding] = useState(false); + const { openSnack } = useSnackbar(); + + useEffect(() => { + //math to determine what the delta pressures to set will be + let celciusTemp = densityTemp; + if (user.settings.temperatureUnit === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { + celciusTemp = densityTemp * 1.8 + 32; + } + let roundedTemp = Math.round(celciusTemp / 5) * 5; + let airDensity = densityMap.get(roundedTemp); + //have default values if the temp is outside the map + if (roundedTemp > 35 && airDensity === undefined) airDensity = 1.1; + if (roundedTemp < -25 && airDensity === undefined) airDensity = 1.5; + + //calculate the c value for the equation + if (airDensity !== undefined) { + let diameterM = gate.ductDiameter() / 1000; + let pieRadSquare = 3.14 * Math.pow(diameterM / 2, 2); + let c = 0.98 * pieRadSquare * Math.sqrt(2 * airDensity); + let qmHigh = gate.upperFlow(); + let qmLow = gate.lowerFlow(); + + setLowDelta(Math.round(Math.pow(qmLow / c, 2))); + setHighDelta(Math.round(Math.pow(qmHigh / c, 2))); + } + + //addresses of controller LEDs on a V1 device + let redAddr = "3-1-512"; + let greenAddr = "3-1-1024"; + if (compDevice.device) { + let dev = Device.create(compDevice.device); + if (dev.settings.product === pond.DeviceProduct.DEVICE_PRODUCT_MIPCA_V2) { + redAddr = "3-1-256"; + greenAddr = "3-1-512"; + } + } + + //need to find if they have LED's in both of the controller positions + compDevice.components.forEach(comp => { + let component = Component.any(comp); + //checks the address for the LED components to get the red and green LED's + + if (component.locationString() === redAddr && component.subType() === 1) { + setRedComponent(component); + } else if (component.locationString() === greenAddr && component.subType() === 1) { + setGreenComponent(component); + } else if (component.type() === quack.ComponentType.COMPONENT_TYPE_PRESSURE_CABLE) { + if ( + gate.preferences[component.key()] === pond.GateComponentType.GATE_COMPONENT_TYPE_PRESSURE + ) { + setPressureComponent(component); + } + } + }); + }, [gate, densityTemp, user, compDevice]); + + // useEffect(() => { + // //load current interactions for the device + // let deviceID = Device.any(compDevice.device).id(); + // interactionsAPI.listInteractionsByDevice(deviceID).then(resp => { + // setCurrentInteractions(resp); + // }); + // }, [compDevice.device, interactionsAPI]); + + // const removeCurrentInteractions = () => { + // let deviceID = Device.any(compDevice.device).id(); + // currentInteractions.forEach(interaction => { + // interactionsAPI.removeInteraction(deviceID, interaction.key()); + // }); + // }; + + const createInteractions = async () => { + //the interactions to be made + + //TOGGLE green ON when pressure within range of upper and lower + if ( + greenComponent !== undefined && + redComponent !== undefined && + pressureComponent !== undefined + ) { + let greenToggle: pond.InteractionSettings = pond.InteractionSettings.create({ + sink: quack.ComponentID.create({ + type: greenComponent.type(), + address: greenComponent.settings.address, + addressType: greenComponent.settings.addressType + }), + source: quack.ComponentID.create({ + type: pressureComponent.type(), + address: pressureComponent.settings.address, + addressType: pressureComponent.settings.addressType + }), + conditions: [ + pond.InteractionCondition.create({ + comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN, + value: -highDelta, + measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE + }), + pond.InteractionCondition.create({ + comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN, + value: -lowDelta, + measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE + }) + ], + nodeOne: 2, + nodeTwo: 1, + subtype: 18, + schedule: pond.InteractionSchedule.create({ + timezone: moment.tz.guess(), + weekdays: ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"], + timeOfDayStart: "00:00", + timeOfDayEnd: "24:00" + }), + result: pond.InteractionResult.create({ + type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_TOGGLE, + value: 1 + }) + }); + + //TOGGLE red OFF when pressure within range of upper and lower + let redToggle: pond.InteractionSettings = pond.InteractionSettings.create({ + sink: quack.ComponentID.create({ + type: redComponent.type(), + address: redComponent.settings.address, + addressType: redComponent.settings.addressType + }), + source: quack.ComponentID.create({ + type: pressureComponent.type(), + address: pressureComponent.settings.address, + addressType: pressureComponent.settings.addressType + }), + conditions: [ + pond.InteractionCondition.create({ + comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN, + value: -highDelta, + measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE + }), + pond.InteractionCondition.create({ + comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN, + value: -lowDelta, + measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE + }) + ], + nodeOne: 2, + nodeTwo: 1, + subtype: 18, + schedule: pond.InteractionSchedule.create({ + timezone: moment.tz.guess(), + weekdays: ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"], + timeOfDayStart: "00:00", + timeOfDayEnd: "24:00" + }), + result: pond.InteractionResult.create({ + type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_TOGGLE, + value: 0 + }) + }); + + let deviceID = Device.any(compDevice.device).id(); + if (deviceID !== undefined) { + let multi = pond.MultiInteractionSettings.create({ + interactions: [greenToggle, redToggle] + }); + interactionsAPI + .addMultiInteractions(deviceID, multi) + .then(resp => { + openSnack("Interactions added"); + }) + .catch(err => { + openSnack("There was a problem adding interactions to the device"); + }) + .finally(() => { + setAdding(false); + }); + } + } + close(false); + }; + + return ( + { + close(true); + }}> + Set Interaction For Light Toggle + + Your Delta Pressures, in pascals, will be: + low = {lowDelta} + high = {highDelta} + {greenComponent ? "" : "Green LED Component not found"} + {redComponent ? "" : "Red LED Component not found"} + + + + + + ); +} diff --git a/src/gate/GateFlowGraph.tsx b/src/gate/GateFlowGraph.tsx new file mode 100644 index 0000000..c37b106 --- /dev/null +++ b/src/gate/GateFlowGraph.tsx @@ -0,0 +1,437 @@ +import { + Box, + Button, + Card, + CardHeader, + CircularProgress, + Grid2 as Grid, + LinearProgress, + Typography +} from "@mui/material"; +import { teal } from "@mui/material/colors"; +import { makeStyles } from "@mui/styles"; +import SingleSetAreaChart, { SSAreaDataPoint } from "charts/SingleSetAreaChart"; +import { Gate } from "models/Gate"; +import moment, { Moment } from "moment"; +import { pond } from "protobuf-ts/pond"; +import { useGateAPI } from "providers"; +import React, { useEffect, useState } from "react"; + +interface Props { + gate: Gate; + device: string | number; + start: Moment; + end: Moment; + ambient?: string; + newXDomain?: number[] | string[]; + pressureComponent?: string; + setPCAState: (state: boolean) => void; + multiGraphZoom?: (domain: number[] | string[]) => void; +} + +const useStyles = makeStyles(() => ({ + calcCard: { + height: 100, + display: "flex", + alignItems: "center" + }, + eventCard: { + height: 100, + paddingX: 10, + display: "flex", + alignItems: "center" + }, + runtimeGrid: { + marginBottom: 2 + }, + eventGrid: { + marginTop: 2 + } +})); + +export default function GateFlowGraph(props: Props) { + const { + gate, + device, + ambient, + pressureComponent, + setPCAState, + start, + end, + newXDomain, + multiGraphZoom + } = props; + const gateAPI = useGateAPI(); + const [flowData, setFlowData] = useState([]); + const [loadingChartData, setLoadingChartData] = useState(false); + const [recent, setRecent] = useState(); + const [runtime, setRuntime] = useState(); + const classes = useStyles(); + const [flowEvents, setFlowEvents] = useState([]); + const [eventsLoading, setEventsLoading] = useState(false); + const eventThreshold = 5; + const idleFlow = 2.44; + + useEffect(() => { + if (loadingChartData) return; + if (ambient && pressureComponent) { + let recent: SSAreaDataPoint | undefined; + setLoadingChartData(true); + gateAPI + .listGateAirflow( + gate.key, + device, + ambient, + pressureComponent, + start.toISOString(), + end.toISOString() + ) + .then(resp => { + let data: SSAreaDataPoint[] = []; + if (resp.data.values) { + let start: Moment | undefined; + let stop: Moment | undefined; + let runtime = 0; + resp.data.values.forEach((val, i) => { + let time = moment(val.time); + let newPoint: SSAreaDataPoint = { + timestamp: time.valueOf(), + value: val.airFlow ?? 0 + }; + + data.push(newPoint); + if (!recent || recent.timestamp < newPoint.timestamp) { + recent = newPoint; + } + + /** determine runtime */ + // set the start time if the values is greater than the idleFlow and start is not already set + if (val.airFlow >= idleFlow && !start) { + start = time; + } + // set the stop time when off or at the last measurements and the start time is set + if ((val.airFlow < idleFlow || i === resp.data.values.length - idleFlow) && start) { + stop = time; + } + // if both start and stop are set calculate add the timeframe to the total runtime + if (start && stop) { + runtime = runtime + stop.diff(start); + start = undefined; + stop = undefined; + } + }); + setRuntime(moment.duration(runtime)); + let state = false; + if ( + data[data.length - 1].value > gate.lowerFlow() && + data[data.length - 1].value < gate.upperFlow() + ) { + state = true; + } + setPCAState(state); + } + setFlowData(data); + setLoadingChartData(false); + setRecent(recent); + }); + } + }, [gateAPI, gate, ambient, pressureComponent, start, end, device, setPCAState]); // eslint-disable-line react-hooks/exhaustive-deps + + const loadFlowEvents = () => { + if (ambient && pressureComponent) { + setEventsLoading(true); + gateAPI + .listGateFlowEvents( + gate.key, + device, + ambient, + pressureComponent, + start.toISOString(), + end.toISOString(), + idleFlow, + eventThreshold + ) + .then(resp => { + console.log(resp); + setFlowEvents(resp.data.events.map(e => pond.AirFlowEvent.fromObject(e))); + }) + .catch(err => {}) + .finally(() => { + setEventsLoading(false); + }); + } + }; + + const flowChart = () => { + return ( + + Mass Air Flow} + subheader={ + recent ? ( + + + + {"Mass Flow Rate: "} + + {recent.value.toFixed(2)} kg/s + +
+
+
+ + + {moment(recent.timestamp).fromNow()} + + +
+ ) : ( + + No Data + + ) + } + /> + {flowData.length !== 0 ? ( + + ) : ( + +
+ + A component may be missing or have no measurements, this data needs the ambient and + pressure components to be set and measuring + +
+
+ )} +
+ ); + }; + + const eventCards = () => { + let totalEvents = 0; + let eventsInside = 0; + let eventsOutside = 0; + let totalTimeS = 0; + let timeSInside = 0; + let timeSOutside = 0; + + flowEvents.forEach(event => { + totalEvents++; + totalTimeS = + totalTimeS + moment.duration(moment(event.start).diff(moment(event.end))).asSeconds(); + + let avg = 0; + let count = 0; + event.readings.forEach(reading => { + avg = avg + reading.airFlow; + count++; + }); + avg = avg / count; + //if the average of the readings for an event are within the range + if (avg < gate.upperFlow() && avg > gate.lowerFlow()) { + eventsInside++; + timeSInside = + timeSInside + moment.duration(moment(event.start).diff(moment(event.end))).asSeconds(); + } else { + eventsOutside++; + timeSOutside = + timeSOutside + moment.duration(moment(event.start).diff(moment(event.end))).asSeconds(); + } + }); + + return ( + + {flowEvents.length > 0 ? ( + + + + + + + Total Events: + + {totalEvents} + + + + + + Total Time: + + {moment.duration(totalTimeS, "s").humanize()} + + + + + + + + + + + + Events Inside: + + {eventsInside} + + + + + + Event Time: + + {moment.duration(timeSInside, "s").humanize()} + + + + + + + + + + + + Events Outside: + + {eventsOutside} + + + + + + Event Time: + + {moment.duration(timeSOutside, "s").humanize()} + + + + + + + + + + + Performance: + + + + {(eventsInside / totalEvents) * 100}% + + + + + + + ) : ( + + {eventsLoading ? ( + + + + ) : ( + + + + )} + + )} + + ); + }; + + return ( + + {runtime && ( + + + + + Approximate Runtime: + + + {runtime.asHours().toFixed(2)} hr + + + + + + + + + Cost to run PCA: + + + $ + {parseFloat( + (runtime.asHours() * gate.settings.hourlyPcaCost).toFixed(2) + ).toLocaleString()} + + + + + + + + + Cost to run APU: + + + $ + {parseFloat( + (runtime.asHours() * gate.settings.hourlyApuCost).toFixed(2) + ).toLocaleString()} + + + + + + + + + Total Cost: + + + $ + {parseFloat( + ( + runtime.asHours() * gate.settings.hourlyApuCost + + runtime.asHours() * gate.settings.hourlyPcaCost + ).toFixed(2) + ).toLocaleString()} + + + Estimated Savings: + + + $ + {parseFloat( + (runtime.asHours() * gate.settings.hourlyApuCost).toFixed(2) + ).toLocaleString()} + + + + + + + )} + {loadingChartData ? : flowChart()} + {eventCards()} + + ); +} diff --git a/src/gate/GateGraphs.tsx b/src/gate/GateGraphs.tsx new file mode 100644 index 0000000..19f2fda --- /dev/null +++ b/src/gate/GateGraphs.tsx @@ -0,0 +1,357 @@ +import { + Avatar, + Box, + Button, + Card, + CardHeader, + LinearProgress, + Theme, + Tooltip, + Typography +} from "@mui/material"; +import { ZoomOut } from "@mui/icons-material"; +import AreaGraph, { AreaData } from "charts/measurementCharts/AreaGraph"; +import MultiLineGraph, { LineData, Point } from "charts/measurementCharts/MultiLineGraph"; +import { GetDefaultDateRange } from "common/time/DateRange"; +import TimeBar from "common/time/TimeBar"; +import UnitMeasurementSummary from "component/UnitMeasurementSummary"; +import { useThemeType } from "hooks"; +import { Component } from "models"; +import { Gate } from "models/Gate"; +import { UnitMeasurement } from "models/UnitMeasurement"; +import moment, { Moment } from "moment"; +import { GetComponentIcon } from "pbHelpers/ComponentType"; +import { describeMeasurement, MeasurementDescriber } from "pbHelpers/MeasurementDescriber"; +import { useGateAPI, useGlobalState } from "providers"; +import React, { useEffect, useState } from "react"; +import { avg } from "utils"; +import GateFlowGraph from "./GateFlowGraph"; +import { makeStyles } from "@mui/styles"; + +interface Props { + gate: Gate; + display: "analytics" | "sensors"; + compMap: Map; + device: string | number; + pressure: string; + ambient: string; + setPCAState: (state: boolean) => void; +} + +const useStyles = makeStyles((theme: Theme) => ({ + card: { + position: "relative", + display: "flex", + height: "100%", + flexDirection: "column", + overflow: "visible" + }, + cardHeader: { + padding: theme.spacing(1), + paddingLeft: theme.spacing(2), + marginRight: 10 + }, + avatarIcon: { + width: 33, + height: 33 + } + }) +); + +export default function GateGraphs(props: Props) { + const { gate, display, compMap, device, pressure, ambient, setPCAState } = props; + const [xDomain, setXDomain] = useState(["dataMin", "dataMax"]); + const [zoomed, setZoomed] = useState(false); + const defaultDateRange = GetDefaultDateRange(); + const [startDate, setStartDate] = useState(defaultDateRange.start); + const [endDate, setEndDate] = useState(defaultDateRange.end); + const themeType = useThemeType(); + const classes = useStyles(); + const [{ user }] = useGlobalState(); + const [compMeasurements, setCompMeasurements] = useState>( + new Map() + ); + const gateAPI = useGateAPI(); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (loading) return; + setLoading(true); + let measurementMap: Map = new Map(); + gateAPI + .listGateMeasurements(gate.key, startDate.toISOString(), endDate.toISOString()) + .then(resp => { + resp.data.measurements.forEach(um => { + let unitMeasurement = UnitMeasurement.any(um, user); + let entry = measurementMap.get(unitMeasurement.componentId); + if (entry) { + entry.push(unitMeasurement); + } else { + measurementMap.set(unitMeasurement.componentId, [unitMeasurement]); + } + }); + setLoading(false); + setCompMeasurements(measurementMap); + }); + }, [gate, endDate, gateAPI, startDate]); // eslint-disable-line react-hooks/exhaustive-deps + + const updateDateRange = (newStartDate: any, newEndDate: any) => { + let range = GetDefaultDateRange(); + range.start = newStartDate; + range.end = newEndDate; + setStartDate(newStartDate); + setEndDate(newEndDate); + }; + + const zoomOut = () => { + setXDomain(["dataMin", "dataMax"]); + setZoomed(false); + }; + + const lineGraph = ( + unitMeasurement: UnitMeasurement, + describer: MeasurementDescriber, + averages?: number + ) => { + if (unitMeasurement.values.length > 0) { + let firstTime: Moment | undefined; + let lastTime: Moment | undefined; + //build line data to pass to graph + let lineData: LineData[] = []; + let averagedData: LineData[] = []; + let valMap: Map = new Map(); + + unitMeasurement.values.forEach((vals, i) => { + let avgVals: Point[] = []; + let newLineData: LineData = { + timestamp: moment(unitMeasurement.timestamps[i]).valueOf(), + points: vals.values.map((val, i) => ({ value: val, node: i })) + }; + lineData.push(newLineData); + if (averages) { + vals.values.forEach((val, k) => { + let entry = valMap.get(k); + if (entry) { + entry.push(val); + if (entry.length >= averages) { + lastTime = moment(unitMeasurement.timestamps[i]); + avgVals.push({ + node: k, + value: avg(entry) + }); + } + } else { + valMap.set(k, [val]); + firstTime = moment(unitMeasurement.timestamps[i]); + } + }); + } + if (firstTime && lastTime) { + averagedData.push({ + points: avgVals, + timestamp: + Math.abs(firstTime.diff(lastTime)) / 2 + + Math.min(firstTime.valueOf(), lastTime.valueOf()) + }); + firstTime = undefined; + lastTime = undefined; + valMap = new Map(); + } + }); + return ( + 1 ? unitMeasurement.values[0].values.length : 0} + describer={describer} + tooltip + newXDomain={xDomain} + multiGraphZoom={domain => { + setXDomain(domain); + setZoomed(true); + }} + multiGraphZoomOut + /> + ); + } + }; + + const areaGraph = ( + unitMeasurement: UnitMeasurement, + describer: MeasurementDescriber, + averages?: number + ) => { + if (unitMeasurement.timestamps.length > 0) { + let firstTime: Moment | undefined; + let lastTime: Moment | undefined; + //build line data to pass to graph + let areaData: AreaData[] = []; + let avgData: AreaData[] = []; + let maxVals: number[] = []; + let minVals: number[] = []; + + unitMeasurement.values.forEach((val, j) => { + let currentMax = Math.min(...val.values); + let currentMin = Math.max(...val.values); + let dataPoint: AreaData = { + timestamp: moment(unitMeasurement.timestamps[j]).valueOf(), + value: [currentMax, currentMin] + }; + if (averages) { + if (minVals.length === 0) { + firstTime = moment(unitMeasurement.timestamps[j]); + } + maxVals.push(currentMax); + minVals.push(currentMin); + if (minVals.length >= averages) { + lastTime = moment(unitMeasurement.timestamps[j]); + } + } + if (firstTime && lastTime) { + avgData.push({ + timestamp: + Math.abs(firstTime.diff(lastTime)) / 2 + + Math.min(firstTime.valueOf(), lastTime.valueOf()), + value: [avg(maxVals), avg(minVals)] + }); + firstTime = undefined; + lastTime = undefined; + maxVals = []; + minVals = []; + } + areaData.push(dataPoint); + }); + return ( + { + setXDomain(domain); + setZoomed(true); + }} + multiGraphZoomOut + /> + ); + } + }; + + const graphHeader = (comp: Component) => { + const componentIcon = GetComponentIcon(comp.settings.type, comp.settings.subtype, themeType); + + return ( + + } + title={{comp.name()}} + subheader={ + UnitMeasurement.create(um, user)) ?? [] + )} + /> + } + /> + ); + }; + + const sensorGraphs = () => { + return ( + + {Array.from(compMeasurements.values()).map((compMeasurement, i) => { + let c = Component.create(); + if (compMeasurement[0]) { + c = compMap.get(compMeasurement[0].componentId) ?? Component.create(); + } + if (compMap.get(c.key())) { + return ( + + {graphHeader(c)} + {compMeasurement.map(um => { + if (um.values[0] && um.values[0].values.length > 1) { + return areaGraph( + um, + describeMeasurement(um.type, c.type(), c.subType()), + c.settings.smoothingAverages + ); + } else { + return lineGraph( + um, + describeMeasurement(um.type, c.type(), c.subType()), + c.settings.smoothingAverages + ); + } + })} + + ); + } else { + return ; + } + })} + + ); + }; + + const analyticGraphs = () => { + return ( + + { + setPCAState(state); + }} + multiGraphZoom={domain => { + setXDomain(domain); + setZoomed(true); + }} + /> + + ); + }; + + const showGraphs = () => { + switch (display) { + case "sensors": + return sensorGraphs(); + case "analytics": + return analyticGraphs(); + default: + return Unknown Graph Type Selected; + } + }; + + return ( + + + + {zoomed && ( + + + + )} + + {loading ? : showGraphs()} + + ); +} diff --git a/src/gate/GateList.tsx b/src/gate/GateList.tsx new file mode 100644 index 0000000..e484c5d --- /dev/null +++ b/src/gate/GateList.tsx @@ -0,0 +1,143 @@ +import { Gate } from "models/Gate"; +import { Box, Card, Theme } from "@mui/material"; +import React, { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useMobile } from "hooks"; +//import MaterialTable from "material-table"; +//import { getTableIcons } from "common/ResponsiveTable"; +import { Terminal } from "models/Terminal"; +import { makeStyles } from "@mui/styles"; + +interface Props { + gates: Gate[]; + terminals: Terminal[]; + useMobile: boolean; + //duplicateGate: (gate: Gate) => void; +} + +const useStyles = makeStyles((theme: Theme) => ({ + gridListTile: { + minHeight: "184px", + height: "auto !important", + width: "184px", + padding: 2 + }, + hidden: { + visibility: "hidden" + }, + gateCard: { + marginBottom: 10, + paddingLeft: 15 + } + })); + +export default function GateList(props: Props) { + const { gates, terminals } = props; + // const history = useHistory(); + const navigate = useNavigate(); + const isMobile = useMobile(); + const classes = useStyles(); + const [terminalMap, setTerminalMap] = useState>(new Map()); + + const goToGate = (gate: Gate) => { + let path = "/terminals/" + gate.key; + navigate(path, { state: gate }) + }; + + useEffect(() => { + let map = new Map(); + terminals.forEach(t => { + map.set(t.key, t.name); + }); + setTerminalMap(map); + }, [terminals]); + + const desktopView = () => { + return (Desktop table) + // return ( + // terminalMap.get(rowData.terminal()) + // }, + // { + // title: "Duct Type", + // field: "settings.ductName", + // headerStyle: { + // fontWeight: 650, + // fontSize: 20 + // } + // }, + // { + // title: "Duct Size(mm)", + // field: "settings.ductDiameter", + // headerStyle: { + // fontWeight: 650, + // fontSize: 20 + // } + // }, + // { + // title: "Duct Length(m)", + // field: "settings.ductLength", + // headerStyle: { + // fontWeight: 650, + // fontSize: 20 + // } + // }, + // { + // title: "PCA Unit", + // field: "settings.pcaType", + // headerStyle: { + // fontWeight: 650, + // fontSize: 20 + // } + // } + // ]} + // data={gates} + // icons={getTableIcons()} + // onRowClick={(_, gate) => { + // gate && goToGate(gate); + // }} + // title={""} + // options={{ + // pageSize: 10 + // }} + // /> + // ); + }; + + const mobileView = () => { + return ( + + {gates.map((gate, i) => ( + { + goToGate(gate); + }}> + + {gate.name} + + + ))} + + ); + }; + + return isMobile || props.useMobile ? mobileView() : desktopView(); +} diff --git a/src/gate/GateSVG.tsx b/src/gate/GateSVG.tsx new file mode 100644 index 0000000..4dd5e74 --- /dev/null +++ b/src/gate/GateSVG.tsx @@ -0,0 +1,374 @@ +import { Box } from "@mui/material"; +import { makeStyles } from "@mui/styles"; +import { useMobile } from "hooks"; +import moment from "moment"; +import { pond } from "protobuf-ts/pond"; +import { useGlobalState } from "providers"; +import React from "react"; + +const useStyles = makeStyles(() => ({ + st0: { + fill: "#434343" + }, + st1: { + stroke: "#F6EB14", + strokeWidth: 4, + strokeMiterlimit: 10 + }, + st2: { + fill: "#272727" + }, + st3: { + stroke: "#272727", + strokeWidth: 6, + strokeMiterlimit: 10 + }, + st4: { + fill: "#FFFFFF" + }, + vals: { + fontSize: 8, + strokeWidth: 0.7, + filter: "drop-shadow(20px 20px 20px rgb(0 0 0))" + }, + fanState: { + fontSize: 5 + }, + fontBase: { + fontWeight: 650, + fill: "white" + } +})); + +interface Props { + finalTemp: number; + ambientTemp: number; + innerTemps: { t1: number; t2: number }; + innerPressures: { p1: number; p2: number }; + ambientSelector: () => JSX.Element; + tempChainSelector: () => JSX.Element; + pressureChainSelector: () => JSX.Element; + pcaState: boolean; + pcaFanState: boolean; + checkInTime: string; +} + +export default function GateSVG(props: Props) { + const { + finalTemp, + ambientTemp, + innerTemps, + innerPressures, + ambientSelector, + tempChainSelector, + pressureChainSelector, + pcaState, + pcaFanState, + checkInTime + } = props; + const classes = useStyles(); + const isMobile = useMobile(); + const [{ user }] = useGlobalState(); + + //old plane that was back facing + // const ghostPlane = () => { + // return ( + // + // ); + // }; + + //add svg's of other types of planes + const frontFacingPlaneWindowed = () => { + return ( + + ); + }; + + const display = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* {ghostPlane()} */} + {frontFacingPlaneWindowed()} + + ); + }; + + const convertFinalTemp = (temp: number) => { + if (user.settings.temperatureUnit === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { + return temp * 1.8 + 32; + } + return temp; + }; + + const renderValues = () => { + let values: JSX.Element[] = []; + //add the final temp display to the array + values.push( + + + {convertFinalTemp(finalTemp).toFixed(2)}° + {user.settings.temperatureUnit === pond.TemperatureUnit.TEMPERATURE_UNIT_CELSIUS + ? "C" + : "F"} + + + ); + //add the ambient temp display to the array + values.push( + + + Ambient: {ambientTemp}° + {user.settings.temperatureUnit === pond.TemperatureUnit.TEMPERATURE_UNIT_CELSIUS + ? "C" + : "F"} + + + ); + //add the inner temps display (t1,t2) to the array + values.push( + + + T1: {innerTemps.t1}° + {user.settings.temperatureUnit === pond.TemperatureUnit.TEMPERATURE_UNIT_CELSIUS + ? "C" + : "F"} + , T2: {innerTemps.t2}° + {user.settings.temperatureUnit === pond.TemperatureUnit.TEMPERATURE_UNIT_CELSIUS + ? "C" + : "F"} + + + ); + //add the inner temps display (t1,t2) to the array + values.push( + + + P1: {innerPressures.p1}{" "} + {user.settings.pressureUnit === pond.PressureUnit.PRESSURE_UNIT_INCHES_OF_WATER + ? "iwg" + : "kPa"} + , P2: {innerPressures.p2}{" "} + {user.settings.pressureUnit === pond.PressureUnit.PRESSURE_UNIT_INCHES_OF_WATER + ? "iwg" + : "kPa"} + + + ); + return values; + }; + + const pcaRed = () => { + return ( + + + + ); + }; + + const pcaGreen = () => { + return ( + + + + ); + }; + + const pcaFan = () => { + return ( + + + PCA Fan: {pcaFanState ? "ON" : "OFF"} + + + ); + }; + + const deviceCheckIn = () => { + return ( + + + Last Checked In: {moment(checkInTime).fromNow()} + + + ); + }; + + return ( + + + + + + {display()} + {renderValues()} + + + {ambientSelector()} + + + {tempChainSelector()} + + + {pressureChainSelector()} + + {pcaRed()} + {pcaGreen()} + {pcaFan()} + {deviceCheckIn()} + + + + ); +} diff --git a/src/gate/GateSettings.tsx b/src/gate/GateSettings.tsx new file mode 100644 index 0000000..a458f17 --- /dev/null +++ b/src/gate/GateSettings.tsx @@ -0,0 +1,574 @@ +import { + DialogActions, + DialogContent, + DialogTitle, + MenuItem, + Select, + TextField, + Button, + Grid2 as Grid, + Box, + InputAdornment, + Stepper, + Step, + StepLabel, + Tabs, + Tab +} from "@mui/material"; +import ResponsiveDialog from "common/ResponsiveDialog"; +import { Terminal } from "models/Terminal"; +import { Gate } from "models/Gate"; +import { pond } from "protobuf-ts/pond"; +import { useTerminalAPI, useGateAPI } from "providers"; +import React, { useEffect, useState } from "react"; +import { useSnackbar } from "hooks"; +//import { useHistory } from "react-router"; +import { useNavigate } from "react-router-dom"; + +import DuctDescriber, { DuctOptions, FindDuctType } from "ducting/DuctDescriber"; + +interface DuctProps { + diameter: number; + length: number; + thermalConductivity: number; + thermalResistance: number; + frictionFactor: number; +} + +interface Props { + open: boolean; + close: (newGate?: Gate) => void; + terminals?: Terminal[]; + gate?: Gate; + long?: number; + lat?: number; +} + +const steps = ["Gate", "Duct"]; + +export default function GateSettings(props: Props) { + const { open, close, terminals, gate, long, lat } = props; + const [gateName, setGateName] = useState(""); + const [lowerFlowBound, setLowerFlowBound] = useState(0); + const [upperFlowBound, setUpperFlowBound] = useState(0); + const [terminalOptions, setTerminalOptions] = useState(terminals); + const [terminalKey, setTerminalKey] = useState(""); + const gateAPI = useGateAPI(); + const terminalAPI = useTerminalAPI(); + const [loadingTerminals, setLoadingTerminals] = useState(false); + const [removeDialog, setRemoveDialog] = useState(false); + const { openSnack } = useSnackbar(); + //const history = useHistory(); + const navigate = useNavigate(); + const ductOptions = DuctOptions(); + const [ductType, setDuctType] = useState(0); + const [ductName, setDuctName] = useState(""); + const [ductProps, setDuctProps] = useState({ + diameter: 0, + length: 0, + frictionFactor: 0, + thermalConductivity: 0, + thermalResistance: 0 + }); + const [pcaUnit, setPcaUnit] = useState(""); + const [activeStep, setActiveStep] = useState(0); + //user set identifier to be shown on the marker on the map + const [terminalIdentifier, setTerminalIdentifier] = useState(""); + const [gateIdentifier, setGateIdentifier] = useState(""); + const [hourlyPCA, setHourlyPCA] = useState(0); + const [hourlyAPU, setHourlyAPU] = useState(0); + + useEffect(() => { + if (!terminals) { + if (loadingTerminals) return; + setLoadingTerminals(true); + terminalAPI + .listTerminals(500, 0) + .then(resp => { + setTerminalOptions(resp.data.terminals.map(a => Terminal.any(a))); + }) + .catch(err => {}) + .finally(() => { + setLoadingTerminals(false); + }); + } else { + setTerminalOptions(terminals); + } + //eslint-disable-next-line react-hooks/exhaustive-deps + }, [terminals, terminalAPI]); + + useEffect(() => { + if (gate) { + setGateName(gate.name); + setTerminalKey(gate.terminal()); + setDuctProps({ + diameter: gate.settings.ductDiameter, + length: gate.settings.ductLength, + frictionFactor: gate.settings.frictionFactor, + thermalConductivity: gate.settings.thermalConductivity, + thermalResistance: gate.settings.thermalResistance + }); + let currentType = FindDuctType( + gate.settings.thermalResistance, + gate.settings.thermalConductivity, + gate.settings.frictionFactor + ); + setDuctType(currentType); + setLowerFlowBound(gate.lowerFlow()); + setUpperFlowBound(gate.upperFlow()); + setPcaUnit(gate.settings.pcaType); + setDuctName(gate.settings.ductName); + setTerminalIdentifier(gate.settings.letterIdentifier ?? ""); + setGateIdentifier(gate.settings.numberIdentifier ?? ""); + setHourlyAPU(gate.settings.hourlyApuCost ?? 0); + setHourlyPCA(gate.settings.hourlyPcaCost ?? 0); + } else { + setGateName(""); + setTerminalKey(""); + } + }, [gate]); + + const confirm = () => { + if (gate) { + let settings = gate.settings; + settings.terminal = terminalKey; + settings.ductDiameter = ductProps.diameter; + settings.ductLength = ductProps.length; + settings.frictionFactor = ductProps.frictionFactor; + settings.thermalConductivity = ductProps.thermalConductivity; + settings.thermalResistance = ductProps.thermalResistance; + settings.lowerFlow = lowerFlowBound; + settings.upperFlow = upperFlowBound; + settings.ductName = ductName; + settings.pcaType = pcaUnit; + settings.letterIdentifier = terminalIdentifier; + settings.numberIdentifier = gateIdentifier.toString(); + settings.hourlyApuCost = hourlyAPU; + settings.hourlyPcaCost = hourlyPCA; + gateAPI + .updateGate(gate.key, gateName, settings) + .then(resp => { + openSnack("Gate update"); + close(); + }) + .catch(err => { + openSnack("Failed to update gate"); + }); + } else { + let settings: pond.GateSettings = pond.GateSettings.create({ + longitude: long, + latitude: lat, + terminal: terminalKey, + ductDiameter: ductProps.diameter, + ductLength: ductProps.length, + frictionFactor: ductProps.frictionFactor, + thermalConductivity: ductProps.thermalConductivity, + thermalResistance: ductProps.thermalResistance, + lowerFlow: lowerFlowBound, + upperFlow: upperFlowBound, + ductName: ductName, + pcaType: pcaUnit, + letterIdentifier: terminalIdentifier, + numberIdentifier: gateIdentifier.toString(), + hourlyApuCost: hourlyAPU, + hourlyPcaCost: hourlyPCA + }); + gateAPI + .addGate(gateName, settings) + .then(resp => { + openSnack("New gate added"); + let newGate = Gate.create( + pond.Gate.create({ key: resp.data.key, name: gateName, settings: settings }) + ); + close(newGate); + }) + .catch(err => { + openSnack("There was a problem adding your gate"); + }); + } + }; + + const remove = () => { + if (gate) { + gateAPI + .removeGate(gate.key) + .then(resp => { + openSnack("Gate Deleted"); + }) + .catch(err => { + openSnack("Failed to delete gate"); + }) + .finally(() => { + navigate("/terminals"); + }); + } + }; + + const stepper = () => { + if (gate === undefined) { + return ( + + {steps.map(label => { + const stepProps: { completed?: boolean } = {}; + const labelProps: { + optional?: React.ReactNode; + } = {}; + return ( + + {label} + + ); + })} + + ); + } + + return ( + + setActiveStep(value)} + indicatorColor="primary" + textColor="primary" + variant="fullWidth" + aria-label="bin tabs" + //classes={{ root: classes.tabs }} + > + {steps.map((step, i) => ( + + ))} + + + ); + }; + + const stepperContent = (step: number) => { + switch (step) { + case 1: + return ductProperties(); + default: + return gateProperties(); + } + }; + + const gateProperties = () => { + //build list of letters for select box + let tiOptions: JSX.Element[] = []; + //adds the letters to the terminal id options + for (let l = 65; l <= 90; l++) { + tiOptions.push( + + {String.fromCharCode(l)} + + ); + } + //add numbers 1-9 for terminal id options + for (let n = 1; n <= 9; n++) { + tiOptions.push( + + {n} + + ); + } + let numberOptions: JSX.Element[] = []; + for (let n = 0; n <= 99; n++) { + numberOptions.push( + + {n} + + ); + } + + return ( + + setGateName(e.target.value)} + /> + + setLowerFlowBound(+e.target.value)} + /> + setUpperFlowBound(+e.target.value)} + /> + setPcaUnit(e.target.value)} + /> + { + setTerminalIdentifier(e.target.value as string); + }}> + + Gate Letter + + {tiOptions} + + setGateIdentifier(e.target.value)}> + + Gate Number + + {numberOptions} + + $/hr + }} + onChange={e => setHourlyPCA(+e.target.value)} + /> + $/hr + }} + onChange={e => setHourlyAPU(+e.target.value)} + /> + + ); + }; + + const ductProperties = () => { + return ( + + { + let type = +e.target.value; + setDuctType(type); + setDuctName("custom"); + if (type > 1) { + let describer = DuctDescriber(type); + setDuctName(describer.name); + setDuctProps({ + diameter: ductProps.diameter, + frictionFactor: describer.frictionFactor, + length: ductProps.length, + thermalConductivity: describer.thermalConductivity, + thermalResistance: describer.thermalResistance + }); + } + }}> + + Select Ducting Type + + {ductOptions.map(duct => ( + + {duct.label} + + ))} + + Custom + + + mm + }} + onChange={e => { + setDuctProps({ ...ductProps, diameter: parseFloat(e.target.value) }); + }} + /> + m + }} + onChange={e => { + setDuctProps({ ...ductProps, length: parseFloat(e.target.value) }); + }} + /> + W/mK + }} + onChange={e => { + setDuctProps({ ...ductProps, thermalConductivity: parseFloat(e.target.value) }); + setDuctType(1); + setDuctName("custom"); + }} + /> + { + setDuctProps({ ...ductProps, frictionFactor: parseFloat(e.target.value) }); + setDuctType(1); + setDuctName("custom"); + }} + /> + K/W + }} + onChange={e => { + setDuctProps({ ...ductProps, thermalResistance: parseFloat(e.target.value) }); + setDuctType(1); + setDuctName("custom"); + }} + /> + + ); + }; + + const removeConfirmation = () => { + return ( + setRemoveDialog(false)}> + Delete Gate + Are you sure you wish to delete this gate? + + + + + + ); + }; + + return ( + close()}> + {removeConfirmation()} + + + {stepper()} + {stepperContent(activeStep)} + + + + + {gate && ( + + )} + + + {(gate || activeStep === 0) && ( + + )} + {!gate && activeStep > 0 && ( + + )} + {(gate || activeStep === 1) && ( + + )} + {!gate && activeStep === 0 && ( + + )} + + + + + ); +} diff --git a/src/models/Gate.ts b/src/models/Gate.ts new file mode 100644 index 0000000..ab7f155 --- /dev/null +++ b/src/models/Gate.ts @@ -0,0 +1,99 @@ +import { cloneDeep } from "lodash"; +import { MarkerData } from "maps/mapMarkers/Markers"; +import { pond } from "protobuf-ts/pond"; +import { or } from "utils/types"; + +export class Gate { + public settings: pond.GateSettings = pond.GateSettings.create(); + public name: string = "Gate"; + public key: string = ""; + public preferences: any = {}; + public gateMutations: any = {}; + public pcaState: pond.PCAState = pond.PCAState.PCA_STATE_UNKNOWN; + + public static create(pb?: pond.Gate): Gate { + let my = new Gate(); + if (pb) { + let g = pond.Gate.fromObject(pb); + my.settings = pond.GateSettings.fromObject(cloneDeep(or(g.settings, {}))); + my.name = g.name; + my.key = g.key; + my.preferences = g.componentPreferences; + my.gateMutations = g.gateMutations; + my.pcaState = g.pcaState; + } + return my; + } + + public static clone(other?: Gate): Gate { + if (other) { + return Gate.create( + pond.Gate.fromObject({ + name: other.name, + key: other.key, + preferences: other.preferences, + gateMutations: other.gateMutations, + settings: cloneDeep(other.settings) + }) + ); + } + return Gate.create(); + } + + public static any(data: any): Gate { + return Gate.create(pond.Gate.fromObject(cloneDeep(data))); + } + + public longitude(): number { + return this.settings.longitude; + } + + public latitude(): number { + return this.settings.latitude; + } + + public terminal(): string { + return this.settings.terminal; + } + + public upperFlow(): number { + return this.settings.upperFlow; + } + + public lowerFlow(): number { + return this.settings.lowerFlow; + } + + public ductDiameter(): number { + return this.settings.ductDiameter; + } + + public gateMarkerColour(): string { + switch (this.pcaState) { + case pond.PCAState.PCA_STATE_OFF: + return "grey"; + case pond.PCAState.PCA_STATE_IN_BOUNDS: + return "green"; + case pond.PCAState.PCA_STATE_OUT_BOUNDS: + return "red"; + default: + return "#337acc"; + } + } + + public getMarkerData( + clickFunc?: (event: React.PointerEvent, index: number, isMobile: boolean) => void, + updateFunc?: (location: pond.Location) => void + ): MarkerData { + let m: MarkerData = { + longitude: this.longitude(), + latitude: this.latitude(), + title: this.name, + colour: this.gateMarkerColour(), + visibleLevels: { min: 15 }, + clickFunc: clickFunc, + updateFunc: updateFunc + }; + return m; + } +} diff --git a/src/models/Terminal.ts b/src/models/Terminal.ts new file mode 100644 index 0000000..14eb8d3 --- /dev/null +++ b/src/models/Terminal.ts @@ -0,0 +1,59 @@ +import { cloneDeep } from "lodash"; +import { MarkerData } from "maps/mapMarkers/Markers"; +import { pond } from "protobuf-ts/pond"; +import { or } from "utils/types"; + +export class Terminal { + public settings: pond.TerminalSettings = pond.TerminalSettings.create(); + public name: string = ""; + public key: string = ""; + + public static create(pb?: pond.Terminal): Terminal { + let my = new Terminal(); + if (pb) { + my.settings = pond.TerminalSettings.fromObject(cloneDeep(or(pb.settings, {}))); + my.name = pb.name; + my.key = pb.key; + } + return my; + } + + public static clone(other?: Terminal): Terminal { + if (other) { + return Terminal.create( + pond.Terminal.fromObject({ + settings: cloneDeep(other.settings) + }) + ); + } + return Terminal.create(); + } + + public static any(data: any): Terminal { + return Terminal.create(pond.Terminal.fromObject(cloneDeep(data))); + } + + public longitude(): number { + return this.settings.longitude; + } + + public latitude(): number { + return this.settings.latitude; + } + + public getMarkerData( + clickFunc?: (event: React.PointerEvent, index: number, isMobile: boolean) => void, + updateFunc?: (location: pond.Location) => void + ): MarkerData { + let m: MarkerData = { + longitude: this.longitude(), + latitude: this.latitude(), + title: this.name, + colour: this.settings.theme?.color ?? "#004f9b", + visibleLevels: { max: 15 }, + clickFunc: clickFunc, + updateFunc: updateFunc + }; + return m; + } +} diff --git a/src/models/index.ts b/src/models/index.ts index fa2b93e..f731969 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -24,3 +24,4 @@ export * from "./Note"; export * from "./FieldMarker"; export * from "./GrainBag"; export * from "./Contract"; +export * from "./Terminal"; diff --git a/src/navigation/Router.tsx b/src/navigation/Router.tsx index 18fe694..6177091 100644 --- a/src/navigation/Router.tsx +++ b/src/navigation/Router.tsx @@ -9,6 +9,8 @@ import { getWhitelabel } from "services/whiteLabel"; import Ventilation from "pages/VentEditor"; import FieldMap from "pages/FieldMap"; import GrainBag from "pages/grainBag"; +import Terminals from "pages/Terminals"; +import Gate from "pages/Gate"; const DeviceHistory = lazy(() => import("pages/DeviceHistory")); const DevicePage = lazy(() => import("pages/Device")); @@ -46,6 +48,7 @@ export default function Router(props: Props) { } /> } /> } /> + } /> ) } @@ -151,6 +154,28 @@ export default function Router(props: Props) { ); }; + const TerminalGatesRoute = () => { + return ( +
+ + } + /> + } + /> + } + /> + +
+ ); + }; + const GroupsRoute = () => { return ( diff --git a/src/navigation/SideNavigator.tsx b/src/navigation/SideNavigator.tsx index 1976d0d..924e359 100644 --- a/src/navigation/SideNavigator.tsx +++ b/src/navigation/SideNavigator.tsx @@ -30,11 +30,13 @@ import { // IsAdCon, // isBXT, IsMiVent, + IsOmniAir, // IsOmniAir } from "services/whiteLabel"; import MiningIcon from "products/ventilation/MiningIcon"; import { useAuth0 } from "@auth0/auth0-react"; import FieldsIcon from "products/AgIcons/FieldsIcon"; +import PlaneIcon from "products/AviationIcons/PlaneIcon"; const drawerWidth = 230; @@ -140,6 +142,7 @@ export default function SideNavigator(props: Props) { const authenticatedSideMenu = () => { const isMiVent = IsMiVent(); const isAg = IsAdaptiveAgriculture() + const isMiPCA = IsOmniAir() return ( {(isAg || user.hasFeature("admin")) && ( @@ -156,6 +159,20 @@ export default function SideNavigator(props: Props) { )} + {(isMiPCA || user.hasFeature("admin")) && ( + + goTo("/terminals")} + classes={getClasses("/terminal")} + > + + + + {open && } + + + )} goTo("/visualFarm")} classes={getClasses("/visualFarm")} > diff --git a/src/pages/Bin.tsx b/src/pages/Bin.tsx index e097e10..eac93cb 100644 --- a/src/pages/Bin.tsx +++ b/src/pages/Bin.tsx @@ -166,7 +166,7 @@ export default function Bin(props: Props) { const classes = useStyles(); // const match = useRouteMatch(); // const binID = binKey ?? match.params.binID; - const binID = useParams<{ binID: string }>()?.binID ?? ""; + const binID = binKey ?? useParams<{ binID: string }>()?.binID ?? ""; const binAPI = useBinAPI(); const [{ user }] = useGlobalState(); const [binLoading, setBinLoading] = useState(false); @@ -269,7 +269,7 @@ export default function Bin(props: Props) { loadRef.current = true; //add the presets to the bin page data load binAPI - .getBinPageData(binKey ?? binID, user.id(), showErrors) + .getBinPageData(binID, user.id(), showErrors) .then(resp => { if (resp.data.grainCompositionNames) { let tempMap: Map = new Map(); @@ -559,7 +559,7 @@ export default function Bin(props: Props) { const goToDevice = (dev: Device) => { if (fromMap) { - navigate("/bins/" + binKey + "/devices/" + dev.id(), { replace: true }); + navigate("/bins/" + binID + "/devices/" + dev.id(), { replace: true }); } else { navigate(window.location.pathname + "/devices/" + dev.id(), { replace: true }); } diff --git a/src/pages/Gate.tsx b/src/pages/Gate.tsx new file mode 100644 index 0000000..310ae3f --- /dev/null +++ b/src/pages/Gate.tsx @@ -0,0 +1,476 @@ +import { Gate as IGate } from "models/Gate"; +//import { MatchParams } from "navigation/Routes"; +//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 { + Box, + ButtonBase, + Card, + CircularProgress, + Drawer, + Grid2 as Grid, + IconButton, + MenuItem, + Tab, + Tabs, + Theme, + Typography +} from "@mui/material"; +import { pond } from "protobuf-ts/pond"; +import DeviceLinkDrawer from "common/DeviceLinkDrawer"; +import { Component, Device, Scope } from "models"; +import GateActions from "gate/GateActions"; +import GateDevice from "gate/GateDevice"; +import ObjectControls from "common/ObjectControls"; +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 { clone } from "lodash"; +import { useNavigate, useParams } from "react-router-dom"; +import { makeStyles } from "@mui/styles"; + + +interface TabPanelProps { + children?: React.ReactNode; + index: any; + value: any; +} + +function TabPanelMine(props: TabPanelProps) { + const { children, value, index, ...other } = props; + + return ( + + ); +} + +interface Props { + gateKey?: string; + useMobile?: boolean; +} + +const useStyles = makeStyles((theme: Theme) => ({ + //const themeType = theme.palette.type; + inactiveButton: { + color: useThemeType() === "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 + }, + activeButton: { + color: useThemeType() === "light" ? theme.palette.common.black : theme.palette.common.white, + backgroundColor: theme.palette.primary.main, + width: theme.spacing(5), + height: theme.spacing(5), + border: "1px solid", + borderColor: theme.palette.divider + }, + drawerPaper: { + height: "100%", + width: "30%" + } +})); + +export default function Gate(props: Props) { + //const match = useRouteMatch(); + //const gateID = props.gateID ?? match.params.gateID; + const { gateKey } = props + const gateID = gateKey ?? useParams<{ gateID: string }>()?.gateID ?? ""; + const classes = useStyles(); + const gateAPI = useGateAPI(); + const userAPI = useUserAPI(); + const [gate, setGate] = useState(IGate.create()); + const [devices, setDevices] = useState>( + new Map() + ); + const [components, setComponents] = useState([]); + const [openDeviceDrawer, setOpenDeviceDrawer] = useState(false); + const [loadingGate, setLoadingGate] = useState(false); + const [{ user, as }] = useGlobalState(); + const [permissions, setPermissions] = useState([]); + const [tabVal, setTabVal] = useState(0); + //const history = useHistory(); + const navigate = useNavigate(); + const [displayTab, setDisplayTab] = useState(0); + const [openNoteDrawer, setOpenNoteDrawer] = useState(false); + const isMobile = useMobile(); + const [devPrefs, setDevPrefs] = useState>(new Map()); + const { openSnack } = useSnackbar(); + const [invalid, setInvalid] = useState(false); + + const goToMap = () => { + navigate("/aviationMap", { state: { long: gate.longitude(), lat:gate.latitude() }}) + }; + + 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]); + + const loadGate = useCallback(() => { + let id = gateID; + setTabVal(0); + if (loadingGate || id === undefined || id === "") return; + setLoadingGate(true); + gateAPI + .getGatePageData(id) + .then(resp => { + //console.log(resp.data); + let p = new Map(); + Object.keys(resp.data.preferences).forEach(k => { + let prefKey = parseInt(k); + p.set( + prefKey, + pond.GatePreferences.fromObject(resp.data.preferences[k]).gateDevice ?? + pond.GateDeviceType.GATE_DEVICE_TYPE_UNKNOWN + ); + }); + setDevPrefs(p); + if (resp.data.gate) { + setGate(IGate.any(resp.data.gate)); + } + if (resp.data.linkedDevices) { + let devMap = new Map(); + resp.data.linkedDevices.forEach(dev => { + if (dev.device?.settings?.deviceId) { + devMap.set(dev.device.settings.deviceId.toString(), dev); + } + }); + setDevices(devMap); + } + if (resp.data.linkedComponents) { + setComponents(resp.data.linkedComponents.map(c => Component.any(c))); + } + }) + .catch(err => { + setInvalid(true); + }) + .finally(() => { + setLoadingGate(false); + }); + //eslint-disable-next-line react-hooks/exhaustive-deps + }, [gateAPI, gateID]); + + useEffect(() => { + loadGate(); + }, [loadGate]); + + const updateGateDevicePrefs = (deviceID: number, newPref: pond.GateDeviceType) => { + gateAPI + .updatePrefs(gate.key, "device", deviceID.toString(), newPref, [gate.key], ["gate"]) + .then(resp => { + openSnack("Updated gate device type"); + // have to do the clone method to force the select box to update + let newPrefMap = clone(devPrefs); + newPrefMap.set(deviceID, newPref); + setDevPrefs(newPrefMap); + }) + .catch(err => { + openSnack("Failed to update device type"); + }); + }; + + const deviceDrawer = () => { + return ( + + None + , + + PCA Unit + + ]} + devicePrefChanged={(device, pref) => { + updateGateDevicePrefs(device.id(), pref); + }} + open={openDeviceDrawer} + close={() => { + setOpenDeviceDrawer(false); + }} + linkedDevices={devices} + linkedComponents={components} + updateLinkedDevices={(device, linked) => { + let devMap = devices; + let id = device.device?.settings?.deviceId; + if (id) { + if (linked) { + gateAPI + .updateLink(gateID, "gate", id.toString(), "device", [ + "read", + "write", + "grant", + "revoke" + ]) + .then(resp => { + if (id) { + devMap.set(id.toString(), device); + setTabVal(id); + } + setDevices(devMap); + }) + .catch(err => { + console.log("error linking device"); + }); + } else { + gateAPI.updateLink(gateID, "gate", id.toString(), "device", []).then(resp => { + if (id) { + devMap.delete(id.toString()); + if (tabVal === id) { + let firstEntry = Array.from(devMap.values())[0]; + if (firstEntry) { + setTabVal(firstEntry.device?.settings?.deviceId ?? -1); + } else { + setTabVal(0); + } + } + } + setDevices(devMap); + }); + } + } + }} + updateLinkedComponents={(deviceID, component, linked) => { + let c = components; + if (linked) { + gateAPI + .updateLink(gateID, "gate", deviceID + ":" + component.key(), "component", [ + "read", + "write", + "grant", + "revoke" + ]) + .then(resp => { + c.push(component); + setComponents([...c]); + }); + } else { + gateAPI + .updateLink(gateID, "gate", deviceID + ":" + component.key(), "component", []) + .then(resp => { + c.forEach((comp, i) => { + if (component.key() === comp.key()) { + c.splice(i, 1); + setComponents([...c]); + } + }); + }); + } + }} + /> + ); + }; + + const gateDisplay = () => { + return ( + + {loadingGate ? ( + + + + + + ) : ( + + + { + setTabVal(newVal); + }} + aria-label="device tabs"> + + + {Array.from(devices.values()).map(dev => { + let name = "Device Not Found"; + let devKey = -1; + if (dev.device && dev.device.settings) { + name = dev.device.settings.name; + devKey = dev.device.settings.deviceId; + } + return ; + })} + + {/* panel to show if there are no devices */} + + { + setOpenDeviceDrawer(true); + }} + style={{ height: 150, width: "100%", margin: -15 }}> + + + + + + + + + Connect Device + + + + Click here to add a device to the gate. To add an additional device to a gate + use the "Link Device" icon on the top right side of this page + + + + + {/* panel to show if there was an issue in the tab */} + + Device Not Found + + {Array.from(devices.values()).map(dev => { + let devKey = 0; + if (dev.device && dev.device.settings) { + devKey = dev.device.settings.deviceId; + if (tabVal === 0) { + setTabVal(devKey); + } + } + return ( + + + + ); + })} + + + )} + + ); + }; + + const noteDrawer = () => { + return ( + { + setOpenNoteDrawer(false); + }} + anchor="right" + classes={{ paper: classes.drawerPaper }}> + + Notes + + + + + + ); + }; + + return ( + + { + setDisplayTab(0); + }} + className={displayTab === 0 ? classes.activeButton : classes.inactiveButton} + component="span"> + + + } + linkDeviceFunction={() => { + setOpenDeviceDrawer(true); + }} + notesButton={ + { + if (props.useMobile || isMobile) { + setDisplayTab(1); + } else { + setOpenNoteDrawer(true); + } + }} + size="small" + style={{ + marginTop: "0.3625rem", + marginBottom: "0.3625rem", + marginRight: "0.5rem" + }} + className={displayTab === 1 ? classes.activeButton : classes.inactiveButton} + component="span"> + + + } + mapFunction={() => { + goToMap(); + }} + actions={} + permissions={permissions} + devices={Array.from(devices.values()).map(compDev => Device.any(compDev.device))} + /> + + {gate.name} + + + {gateDisplay()} + + {/* tab for notes on mobile and the map drawer */} + + + + + + {/* drawer is for displaying notes on desktop */} + {noteDrawer()} + {deviceDrawer()} + + ); +} diff --git a/src/pages/Terminals.tsx b/src/pages/Terminals.tsx new file mode 100644 index 0000000..1c11035 --- /dev/null +++ b/src/pages/Terminals.tsx @@ -0,0 +1,509 @@ +import { + Tab, + Tabs, + Button, + Theme, + useTheme, + Menu, + MenuItem, + ListItemIcon, + ListItemText, + DialogTitle, + DialogContent, + DialogActions +} from "@mui/material"; +import { Terminal } from "models"; +import { useTerminalAPI, useGlobalState } from "providers"; +import React, { useCallback, useEffect, useState } from "react"; +import GateSettings from "gate/GateSettings"; +import PageContainer from "./PageContainer"; +import AddIcon from "@mui/icons-material/Add"; +import TerminalSettings from "terminal/TerminalSettings"; +import { Delete, MoreVert, ExitToApp as RemoveSelfIcon } from "@mui/icons-material"; +import EditIcon from "@mui/icons-material/Edit"; +import { blue, green, red } from "@mui/material/colors"; +import ResponsiveDialog from "common/ResponsiveDialog"; +import { useSnackbar, useUserAPI } from "hooks"; +import { Gate } from "models/Gate"; +import GateList from "gate/GateList"; +import { Scope } from "models"; +import { pond } from "protobuf-ts/pond"; +import ObjectUsers from "user/ObjectUsers"; +import ObjectTeams from "teams/ObjectTeams"; +import ObjectUsersIcon from "@mui/icons-material/AccountCircle"; +import ObjectTeamsIcon from "@mui/icons-material/SupervisedUserCircle"; +import ShareObject from "user/ShareObject"; +import RemoveSelfFromObject from "user/RemoveSelfFromObject"; +import AddGateFab from "gate/AddGateFab"; +import { makeStyles } from "@mui/styles"; + +const parentTab = { + "&": { + margin: "0px", + marginTop: "4px", + marginLeft: "4px", + animationDuration: "10s", + background: "rgba(150, 150, 150, 0)", + + borderRadius: "-5px", + borderTopLeftRadius: "6px", + borderTopRightRadius: "6px" + }, + "&:hover": { + background: "linear-gradient(rgba(150, 150, 150, 0.2), rgba(150, 150, 150, 0))" + } +}; + +const useStyles = makeStyles((theme: Theme) => ({ + tab: { + ...parentTab, + minWidth: theme.spacing(25), + left: 0, + height: "100%", + background: theme.palette.background.paper + }, + smallTab: { + ...parentTab, + width: theme.spacing(8), + minWidth: theme.spacing(8), + background: theme.palette.background.paper + }, + selectedTab: { + borderRadius: "-5px", + borderTopLeftRadius: "6px", + borderTopRightRadius: "6px", + background: theme.palette.background.default, + "&:hover": { + background: + "linear-gradient(rgba(150, 150, 150, 0.3)," + theme.palette.background.default + " 80%)" + } + }, + tabText: { + position: "absolute", + left: theme.spacing(2), + color: "transparent", + textAlign: "left", + width: "80%", + backgroundImage: "linear-gradient(to right, white 70%, rgba(200, 200, 200, 0) 87.5%)", + backgroundClip: "text", + WebkitBackgroundClip: "text", + overflowX: "hidden", + whiteSpace: "nowrap", + top: 12 + }, + icon: { + display: "flex", + position: "absolute", + right: 0, + top: 6, + padding: 6, + marginRight: "2px", + width: 36, + height: 36, + borderRadius: "18px", + background: "rgba(0,0,0,0)", + "&:hover": { + background: + "radial-gradient(closest-side, rgba(150, 150, 150, 0.5) 50%, rgba(150, 150, 150, 0.5))" + } + }, + green: { + marginRight: 0, + paddingRight: 0, + color: green["500"], + "&:hover": { + color: green["600"] + } + }, + red: { + marginRight: 0, + paddingRight: 0, + color: red["500"], + "&:hover": { + color: red["600"] + } + }, + blueIcon: { + color: blue["500"], + "&:hover": { + color: blue["600"] + } + }, + addGate: { + position: "absolute", + bottom: 20, + right: 20, + backgroundColor: theme.palette.primary.main + } +})); + +interface Props { + terminal?: Terminal; +} + +export default function Terminals(props: Props) { + const [loading, setLoading] = useState(false); + const terminalAPI = useTerminalAPI(); + const [value, setValue] = useState(0); + const [terminals, setTerminals] = useState([]); + const [gates, setGates] = useState([]); + // map using the terminal key and the gates for that terminal + const [gateMap, setGateMap] = useState>(new Map()); + const [displayGates, setDisplayGates] = useState([]); + const [gateDialog, setGateDialog] = useState(false); + const classes = useStyles(); + const theme = useTheme(); + const [addTerminal, setAddTerminal] = useState(false); + const [tabClick, setTabClick] = useState(true); + const [menuAnchorEl, setMenuAnchorEl] = useState(null); + const [currentTerminal, setCurrentTerminal] = useState(); + const [removeTerminal, setRemoveTerminal] = useState(false); + const { openSnack } = useSnackbar(); + const [{ user, as }] = useGlobalState(); + const userAPI = useUserAPI(); + const [permissions, setPermissions] = useState([]); + const [userSharing, setUserSharing] = useState(false); + const [teamSharing, setTeamSharing] = useState(false); + const [baseShareDialog, setBaseShareDialog] = useState(false); + const [removeSelfDialog, setRemoveSelfDialog] = useState(false); + + useEffect(() => { + if (currentTerminal) { + let key = currentTerminal?.key; + let kind = "terminal"; + if (as) { + key = as; + kind = "team"; + } + userAPI.getUser(user.id(), { key: key, kind: kind } as Scope).then(resp => { + setPermissions(resp.permissions); + }); + } + }, [as, userAPI, user, currentTerminal]); + + useEffect(() => { + if (props.terminal) { + setCurrentTerminal(props.terminal); + setDisplayGates(gateMap.get(props.terminal.key) ?? []); + } + }, [props.terminal, gateMap]); + + const load = useCallback(() => { + console.log(loading) + if (loading) return; + console.log("terminal load") + setLoading(true); + terminalAPI + .listTerminalsAndGates(200, 0) + .then(resp => { + console.log(resp) + setTerminals(resp.data.terminals.map(a => Terminal.any(a))); + let allGates = resp.data.gates.map(t => Gate.any(t)); + setGates(allGates); + let tMap: Map = new Map(); + allGates.forEach(gate => { + let mapEntry = tMap.get(gate.terminal()); + if (mapEntry) { + mapEntry.push(gate); + } else { + tMap.set(gate.terminal(), [gate]); + } + setGateMap(tMap); + setDisplayGates(allGates); + }); + }) + .catch(err => { + openSnack("There was a problem loading your terminals"); + }) + .finally(() => { + setLoading(false); + }); + }, [terminalAPI, openSnack]); //eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + console.log("load use") + load(); + }, [load]); + + const remove = () => { + if (currentTerminal) { + terminalAPI + .removeTerminal(currentTerminal.key) + .then(resp => { + openSnack("Terminal Deleted"); + let a = terminals; + a.splice(terminals.indexOf(currentTerminal), 1); + setTerminals([...a]); + }) + .catch(err => { + openSnack("Failed to delete terminal"); + }) + .finally(() => { + setRemoveTerminal(false); + }); + } + }; + + const terminalMenu = () => { + if (currentTerminal === undefined) return; + return ( + { + setMenuAnchorEl(null); + }}> + {permissions.includes(pond.Permission.PERMISSION_WRITE) && ( + { + setAddTerminal(true); + setMenuAnchorEl(null); + }} + aria-label="Edit Terminal" + dense> + + + + + + )} + {permissions.includes(pond.Permission.PERMISSION_WRITE) && ( + { + setRemoveTerminal(true); + setMenuAnchorEl(null); + }} + aria-label="Remove Terminal" + dense> + + + + + + )} + {permissions.includes(pond.Permission.PERMISSION_USERS) && ( + { + setUserSharing(true); + setMenuAnchorEl(null); + }}> + + + + + + )} + {permissions.includes(pond.Permission.PERMISSION_USERS) && ( + { + setTeamSharing(true); + setMenuAnchorEl(null); + }}> + + + + + + )} + { + setRemoveSelfDialog(true); + setMenuAnchorEl(null); + }}> + + + + + + + ); + }; + + const changeTabs = (newValue: number) => { + if (tabClick && newValue <= terminals.length) { + setValue(newValue); + if (terminals[newValue - 1]) { + setDisplayGates(gateMap.get(terminals[newValue - 1].key) ?? []); + } else { + setDisplayGates(gates); + } + } + }; + + const gateDisplay = () => { + return ( + + ); + }; + + //could update the tabs in the same way as the bin yard tabs + const terminalTabs = () => { + return ( + + changeTabs(v)} + TabIndicatorProps={{ style: { background: "rgba(0,0,0,0)" } }}> + + {terminals.map((a, index) => ( + +
{a.name}
+ setTabClick(false)} + onMouseLeave={() => setTabClick(true)} + className={classes.icon} + onClick={event => { + let target = event.currentTarget; + setMenuAnchorEl(target); + setCurrentTerminal(terminals[index]); + }} + /> + + } + /> + ))} + + } + onClick={() => { + setCurrentTerminal(undefined); + setAddTerminal(true); + }} + disableTouchRipple={true} + /> +
+
+ ); + }; + + const removeDialog = () => { + return ( + { + setRemoveTerminal(false); + }}> + Delete Terminal + Are you sure you wish to delete this Terminal? + + + + + + ); + }; + + const sharingDialogs = () => { + if (!currentTerminal) return; + return ( + + { + setBaseShareDialog(false); + }} + /> + setUserSharing(false)} + refreshCallback={() => {}} + /> + setTeamSharing(false)} + refreshCallback={() => { + return true; + }} + /> + { + if (removed) { + let a = terminals; + a.splice(terminals.indexOf(currentTerminal), 1); + setTerminals([...a]); + } + setRemoveSelfDialog(false); + }} + /> + + ); + }; + + return ( + + { + if (newGate) { + if (newGate.terminal() !== "") { + let tMap = gateMap; + tMap.get(newGate.terminal())?.push(newGate); + } + let t = gates; + t.push(newGate); + setGates([...t]); + } + setGateDialog(false); + }} + terminals={terminals} + /> + { + if (newTerminal) { + let a = terminals; + a.push(newTerminal); + setTerminals([...a]); + } + setAddTerminal(false); + }} + terminal={currentTerminal} + /> + {!props.terminal && terminalTabs()} + {terminalMenu()} + {removeDialog()} + {sharingDialogs()} + {gateDisplay()} + { + setGateDialog(true); + }} + pulse={gates.length < 1} + /> + + ); +} diff --git a/src/providers/index.ts b/src/providers/index.ts index ec95aa9..864b919 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -36,7 +36,7 @@ export { // useDataDogProxyAPI, useMineAPI, // useKeyManagerAPI, - // useTerminalAPI, + useTerminalAPI, //TODO: update api with resolve,reject useGateAPI, // useObjectHeaterAPI, // useMutationAPI, diff --git a/src/providers/pond/gateAPI.tsx b/src/providers/pond/gateAPI.tsx index 7d4acdf..2c1c08f 100644 --- a/src/providers/pond/gateAPI.tsx +++ b/src/providers/pond/gateAPI.tsx @@ -1,5 +1,5 @@ import { useHTTP } from "hooks"; -import React, { createContext, PropsWithChildren, useContext } from "react"; +import { createContext, PropsWithChildren, useContext } from "react"; import { pond } from "protobuf-ts/pond"; import { pondURL } from "./pond"; import { AxiosResponse } from "axios"; @@ -55,6 +55,18 @@ export interface IGateInterface { keys?: string[], types?: string[] ) => Promise>; + listGateFlowEvents: ( + gate: string, + device: number | string, + ambientComponent: string, + pressureComponent: string, + start: string, + end: string, + idleFlow: number, + flowVariance: number, + keys?: string[], + types?: string[] + ) => Promise>; listGateMeasurements: ( key: string, start: string, @@ -274,6 +286,43 @@ export default function GateProvider(props: PropsWithChildren) { }) }; + const listGateFlowEvents = ( + gate: string, + device: number | string, + ambientComponent: string, + pressureComponent: string, + start: string, + end: string, + idleFlow: number, + flowVariance: number, + keys?: string[], + types?: string[] + ) => { + return get( + pondURL( + "/gates/" + + gate + + "/airflowEvents?device=" + + device + + "&ambient=" + + ambientComponent + + "&pressure=" + + pressureComponent + + "&start=" + + start + + "&end=" + + end + + "&idle=" + + idleFlow + + "&variance=" + + flowVariance + + (as ? "&as=" + as : "") + + (keys ? "&keys=" + keys.toString() : "") + + (types ? "&types=" + types.toString() : "") + ) + ); + }; + return ( ) { getGatePageData, updatePrefs, listGateAirflow, - listGateMeasurements + listGateMeasurements, + listGateFlowEvents }}> {children} diff --git a/src/providers/pond/pond.tsx b/src/providers/pond/pond.tsx index 471f465..c882a6f 100644 --- a/src/providers/pond/pond.tsx +++ b/src/providers/pond/pond.tsx @@ -24,6 +24,7 @@ import MineProvider, { useMineAPI } from "./mineAPI"; import FieldMarkerProvider, {useFieldMarkerAPI} from "./fieldMarkerAPI"; import HomeMarkerProvider, {useHomeMarkerAPI} from "./homeMarkerAPI"; import HarvestPlanProvider, {useHarvestPlanAPI} from "./harvestPlanAPI"; +import TerminalProvider, {useTerminalAPI} from "./terminalAPI" // import NoteProvider from "providers/noteAPI"; export const pondURL = (partial: string, demo: boolean = false): string => { @@ -64,7 +65,9 @@ export default function PondProvider(props: PropsWithChildren) { - {children} + + {children} + @@ -115,5 +118,6 @@ export { useMineAPI, useFieldMarkerAPI, useHomeMarkerAPI, - useHarvestPlanAPI + useHarvestPlanAPI, + useTerminalAPI }; diff --git a/src/providers/pond/terminalAPI.tsx b/src/providers/pond/terminalAPI.tsx new file mode 100644 index 0000000..94ef7d0 --- /dev/null +++ b/src/providers/pond/terminalAPI.tsx @@ -0,0 +1,155 @@ +import { useHTTP } from "hooks"; +import { createContext, PropsWithChildren, useContext } from "react"; +import { pond } from "protobuf-ts/pond"; +import { pondURL } from "./pond"; +import { AxiosResponse } from "axios"; +import { useGlobalState } from "providers"; + +export interface ITerminalAPIContext { + addTerminal: ( + name: string, + terminal: pond.TerminalSettings + ) => Promise>; + listTerminals: ( + limit: number, + offset: number, + order?: "asc" | "desc", + orderBy?: string, + search?: string + ) => Promise>; + listTerminalsAndGates: ( + limit: number, + offset: number, + order?: "asc" | "desc", + orderBy?: string, + search?: string + ) => Promise>; + updateTerminal: ( + key: string, + name: string, + settings: pond.TerminalSettings + ) => Promise>; + removeTerminal: (key: string) => Promise>; +} + +export const TerminalAPIContext = createContext({} as ITerminalAPIContext); + +interface Props {} + +export default function TerminalProvider(props: PropsWithChildren) { + const { children } = props; + const { get, del, post, put } = useHTTP(); + const [{ as }] = useGlobalState(); + + const addTerminal = (name: string, terminal: pond.TerminalSettings) => { + if (as) + return post( + pondURL("/terminals?name=" + name + "&as=" + as), + terminal + ); + return post(pondURL("/terminals?name=" + name), terminal); + }; + + const listTerminals = ( + limit: number, + offset: number, + order?: "asc" | "desc", + orderBy?: string, + search?: string + ) => { + if (as) + return get( + pondURL( + "/terminals?as=" + + as + + "&limit=" + + limit + + "&offset=" + + offset + + ("&order=" + (order ? order : "asc")) + + ("&by=" + (orderBy ? orderBy : "key")) + + (search ? "&search=" + search : "") + ) + ); + return get( + pondURL( + "/terminals?limit=" + + limit + + "&offset=" + + offset + + ("&order=" + (order ? order : "asc")) + + ("&by=" + (orderBy ? orderBy : "key")) + + (search ? "&search=" + search : "") + ) + ); + }; + + const listTerminalsAndGates = ( + limit: number, + offset: number, + order?: "asc" | "desc", + orderBy?: string, + search?: string + ) => { + if (as) + return get( + pondURL( + "/terminalsAndGates?as=" + + as + + "&limit=" + + limit + + "&offset=" + + offset + + ("&order=" + (order ? order : "asc")) + + ("&by=" + (orderBy ? orderBy : "key")) + + (search ? "&search=" + search : "") + ) + ); + return get( + pondURL( + "/terminalsAndGates?limit=" + + limit + + "&offset=" + + offset + + ("&order=" + (order ? order : "asc")) + + ("&by=" + (orderBy ? orderBy : "key")) + + (search ? "&search=" + search : "") + ) + ); + }; + + const updateTerminal = (key: string, name: string, settings: pond.TerminalSettings) => { + if (as) { + return put( + pondURL("/terminals/" + key + "?as=" + as + "&name=" + name), + settings + ); + } + return put( + pondURL("/terminals/" + key + "?name=" + name), + settings + ); + }; + + const removeTerminal = (key: string) => { + if (as) { + return del(pondURL("/terminals/" + key + "?as=" + as)); + } + return del(pondURL("/terminals/" + key)); + }; + + return ( + + {children} + + ); +} + +export const useTerminalAPI = () => useContext(TerminalAPIContext); diff --git a/src/terminal/TerminalSettings.tsx b/src/terminal/TerminalSettings.tsx new file mode 100644 index 0000000..8cf694b --- /dev/null +++ b/src/terminal/TerminalSettings.tsx @@ -0,0 +1,92 @@ +import { Button, DialogActions, DialogContent, DialogTitle, TextField } from "@mui/material"; +import ResponsiveDialog from "common/ResponsiveDialog"; +import { useEffect, useState } from "react"; +import { useTerminalAPI } from "providers"; +import { useSnackbar } from "hooks"; +import { pond } from "protobuf-ts/pond"; +import { Terminal } from "models/Terminal"; + +interface Props { + open: boolean; + closeDialog: (newTerminal?: Terminal, updateTerminal?: Terminal) => void; + long?: number; + lat?: number; + terminal?: Terminal; +} + +export default function TerminalSettings(props: Props) { + const { open, closeDialog, terminal, long, lat } = props; + const terminalAPI = useTerminalAPI(); + const [name, setName] = useState(""); + const { openSnack } = useSnackbar(); + + useEffect(() => { + if (terminal) { + setName(terminal.name); + } else { + setName(""); + } + }, [terminal]); + + const confirmChanges = () => { + if (terminal) { + //change settings here, currently no settings to change as the long/lat are changed from the map + + terminal.name = name; + //note: changing the terminal object passed in WILL in fact change it in the parent + terminalAPI + .updateTerminal(terminal.key, terminal.name, terminal.settings) + .then(resp => { + openSnack("Terminal updated"); + closeDialog(); + }) + .catch(err => { + openSnack("There was a problem updating the terminal"); + }); + } else { + let settings: pond.TerminalSettings = pond.TerminalSettings.create({ + longitude: long, + latitude: lat + }); + terminalAPI + .addTerminal(name, settings) + .then(resp => { + openSnack("New terminal added"); + let newTerminal = Terminal.create( + pond.Terminal.create({ key: resp.data.key, name: name, settings: settings }) + ); + closeDialog(newTerminal); + }) + .catch(err => { + openSnack("There was a problem adding the terminal"); + }); + } + }; + + return ( + closeDialog()} fullWidth> + Terminal Settings + + setName(e.target.value)} + /> + + + + + + + ); +}