From 45ff49bceaa8374d33167534693e2ed27a31c3ff Mon Sep 17 00:00:00 2001 From: Carter Date: Tue, 11 Feb 2025 16:31:03 -0600 Subject: [PATCH] added tons of files for component functionality; manual component adding added temporarily --- package-lock.json | 933 ++++- package.json | 11 +- src/charts/GrainDryingChart.tsx | 200 ++ src/charts/GraphSettings.tsx | 134 + src/charts/MaterialChartTooltip.tsx | 47 + src/charts/MeasurementsChart.tsx | 410 +++ src/charts/measurementCharts/AreaGraph.tsx | 213 ++ .../measurementCharts/MultiLineGraph.tsx | 259 ++ src/common/EventBlocker.tsx | 36 + src/common/Graph.tsx | 855 +++++ src/common/RangeInput.tsx | 79 + src/component/AddComponentManualDialog.tsx | 98 + src/component/AddCompsFromDiag.tsx | 360 ++ src/component/ComponentActions.tsx | 213 ++ src/component/ComponentCard.tsx | 390 ++ src/component/ComponentChart.tsx | 824 +++++ src/component/ComponentDiagnostics.tsx | 266 ++ src/component/ComponentForm.tsx | 1322 +++++++ src/component/ComponentHistory.tsx | 69 + src/component/ComponentMode.json | 139 + src/component/ComponentOrder.tsx | 184 + src/component/ComponentSettings.tsx | 1055 ++++++ src/component/ComponentTypeChip.tsx | 61 + src/component/ExportDataSettings.tsx | 414 +++ src/component/GPS.tsx | 116 + src/component/HumidityIcon.tsx | 38 + src/component/MeasurementSummary.tsx | 278 ++ src/component/SensorLight.tsx | 104 + src/component/TemperatureIcon.tsx | 37 + src/component/UnitMeasurementSummary.tsx | 311 ++ src/device/DeviceActions.tsx | 20 +- src/device/LoadDeviceProfile.tsx | 162 + src/fans/fanPicker.tsx | 417 +++ src/fans/fans_client.json | 3130 +++++++++++++++++ src/grain/GrainDescriber.ts | 523 +++ src/grain/GrainDryingCalculator.tsx | 389 ++ src/grain/GrainInventory.tsx | 385 ++ src/grain/GrainMoisture.ts | 168 + src/grain/GrainTransaction.tsx | 531 +++ src/grain/GrainUse.ts | 40 + src/grain/GrainUtils.ts | 45 + src/grain/index.ts | 5 + src/hooks/index.ts | 4 +- src/interactions/InteractionSettings.tsx | 1603 +++++++++ src/interactions/InteractionsOverview.tsx | 344 ++ src/interactions/RemoveInteraction.tsx | 71 + src/models/Component.ts | 21 +- src/models/Device.ts | 65 +- src/models/Interaction.ts | 146 + src/models/UnitMeasurement.ts | 260 ++ src/models/index.ts | 2 +- src/pages/Device.tsx | 44 +- src/pbHelpers/AddressType.ts | 91 + .../AddressTypes/ConfigurablePinArray.ts | 67 + src/pbHelpers/AddressTypes/DAC.ts | 11 + src/pbHelpers/AddressTypes/GPS.ts | 11 + src/pbHelpers/AddressTypes/I2C.ts | 30 + src/pbHelpers/AddressTypes/I2CWithChannels.ts | 22 + src/pbHelpers/AddressTypes/Incremental.ts | 11 + src/pbHelpers/AddressTypes/Invalid.ts | 11 + src/pbHelpers/AddressTypes/Modem.ts | 11 + src/pbHelpers/AddressTypes/Power.ts | 11 + src/pbHelpers/AddressTypes/SPI.ts | 20 + src/pbHelpers/AddressTypes/index.ts | 9 + src/pbHelpers/Component.ts | 157 + src/pbHelpers/ComponentType.tsx | 1106 ++++++ src/pbHelpers/ComponentTypes/AirQuality.ts | 82 + src/pbHelpers/ComponentTypes/Airflow.ts | 78 + src/pbHelpers/ComponentTypes/AnalogInput.ts | 113 + src/pbHelpers/ComponentTypes/CO2.ts | 76 + src/pbHelpers/ComponentTypes/Calcium.ts | 71 + src/pbHelpers/ComponentTypes/Capacitance.ts | 86 + .../ComponentTypes/CapacitorCable.ts | 212 ++ src/pbHelpers/ComponentTypes/Conductivity.ts | 71 + src/pbHelpers/ComponentTypes/DHT.ts | 114 + .../ComponentTypes/DragerGasDongle.ts | 216 ++ src/pbHelpers/ComponentTypes/Ethylene.ts | 71 + src/pbHelpers/ComponentTypes/GPS.ts | 107 + src/pbHelpers/ComponentTypes/GrainCable.ts | 455 +++ src/pbHelpers/ComponentTypes/Invalid.ts | 63 + src/pbHelpers/ComponentTypes/Lidar.ts | 102 + src/pbHelpers/ComponentTypes/Light.ts | 71 + src/pbHelpers/ComponentTypes/Modem.ts | 81 + src/pbHelpers/ComponentTypes/Nitrate.ts | 71 + src/pbHelpers/ComponentTypes/O2.ts | 71 + src/pbHelpers/ComponentTypes/ORP.ts | 71 + src/pbHelpers/ComponentTypes/OnOffInput.ts | 71 + src/pbHelpers/ComponentTypes/OnOffOutput.ts | 140 + src/pbHelpers/ComponentTypes/PH.ts | 71 + src/pbHelpers/ComponentTypes/Potassium.ts | 71 + src/pbHelpers/ComponentTypes/Power.ts | 93 + src/pbHelpers/ComponentTypes/Pressure.ts | 98 + src/pbHelpers/ComponentTypes/PressureCable.ts | 207 ++ src/pbHelpers/ComponentTypes/Sen5x.ts | 130 + src/pbHelpers/ComponentTypes/StepperMotor.ts | 74 + src/pbHelpers/ComponentTypes/Temperature.ts | 93 + src/pbHelpers/ComponentTypes/Trigger.ts | 183 + src/pbHelpers/ComponentTypes/VPD.ts | 113 + .../ComponentTypes/VibrationCable.ts | 154 + src/pbHelpers/ComponentTypes/Voltage.ts | 71 + src/pbHelpers/ComponentTypes/Weight.ts | 71 + src/pbHelpers/ComponentTypes/index.ts | 31 + src/pbHelpers/DeviceAvailability.ts | 179 + src/pbHelpers/Enums.ts | 65 + src/pbHelpers/Interaction.ts | 435 +++ src/pbHelpers/MeasurementDescriber.ts | 688 ++++ src/providers/index.ts | 4 +- src/providers/pond/componentAPI.tsx | 530 +++ src/providers/pond/interactionsAPI.tsx | 250 ++ src/providers/pond/pond.tsx | 10 +- src/services/cache.js | 61 + src/services/feature/service.ts | 13 + src/services/google/googleAPI.ts | 44 + src/services/google/mapHelpers.ts | 38 + src/services/restAPI.js | 23 + src/utils/download.ts | 88 + src/utils/index.ts | 6 + src/utils/numbers.ts | 18 + src/utils/random.ts | 66 + src/utils/units.ts | 74 + tsconfig.json | 4 + 121 files changed, 25883 insertions(+), 65 deletions(-) create mode 100644 src/charts/GrainDryingChart.tsx create mode 100644 src/charts/GraphSettings.tsx create mode 100644 src/charts/MaterialChartTooltip.tsx create mode 100644 src/charts/MeasurementsChart.tsx create mode 100644 src/charts/measurementCharts/AreaGraph.tsx create mode 100644 src/charts/measurementCharts/MultiLineGraph.tsx create mode 100644 src/common/EventBlocker.tsx create mode 100644 src/common/Graph.tsx create mode 100644 src/common/RangeInput.tsx create mode 100644 src/component/AddComponentManualDialog.tsx create mode 100644 src/component/AddCompsFromDiag.tsx create mode 100644 src/component/ComponentActions.tsx create mode 100644 src/component/ComponentCard.tsx create mode 100644 src/component/ComponentChart.tsx create mode 100644 src/component/ComponentDiagnostics.tsx create mode 100644 src/component/ComponentForm.tsx create mode 100644 src/component/ComponentHistory.tsx create mode 100644 src/component/ComponentMode.json create mode 100644 src/component/ComponentOrder.tsx create mode 100644 src/component/ComponentSettings.tsx create mode 100644 src/component/ComponentTypeChip.tsx create mode 100644 src/component/ExportDataSettings.tsx create mode 100644 src/component/GPS.tsx create mode 100644 src/component/HumidityIcon.tsx create mode 100644 src/component/MeasurementSummary.tsx create mode 100644 src/component/SensorLight.tsx create mode 100644 src/component/TemperatureIcon.tsx create mode 100644 src/component/UnitMeasurementSummary.tsx create mode 100644 src/device/LoadDeviceProfile.tsx create mode 100644 src/fans/fanPicker.tsx create mode 100644 src/fans/fans_client.json create mode 100644 src/grain/GrainDescriber.ts create mode 100644 src/grain/GrainDryingCalculator.tsx create mode 100644 src/grain/GrainInventory.tsx create mode 100644 src/grain/GrainMoisture.ts create mode 100644 src/grain/GrainTransaction.tsx create mode 100644 src/grain/GrainUse.ts create mode 100644 src/grain/GrainUtils.ts create mode 100644 src/grain/index.ts create mode 100644 src/interactions/InteractionSettings.tsx create mode 100644 src/interactions/InteractionsOverview.tsx create mode 100644 src/interactions/RemoveInteraction.tsx create mode 100644 src/models/Interaction.ts create mode 100644 src/models/UnitMeasurement.ts create mode 100644 src/pbHelpers/AddressType.ts create mode 100644 src/pbHelpers/AddressTypes/ConfigurablePinArray.ts create mode 100644 src/pbHelpers/AddressTypes/DAC.ts create mode 100644 src/pbHelpers/AddressTypes/GPS.ts create mode 100644 src/pbHelpers/AddressTypes/I2C.ts create mode 100644 src/pbHelpers/AddressTypes/I2CWithChannels.ts create mode 100644 src/pbHelpers/AddressTypes/Incremental.ts create mode 100644 src/pbHelpers/AddressTypes/Invalid.ts create mode 100644 src/pbHelpers/AddressTypes/Modem.ts create mode 100644 src/pbHelpers/AddressTypes/Power.ts create mode 100644 src/pbHelpers/AddressTypes/SPI.ts create mode 100644 src/pbHelpers/AddressTypes/index.ts create mode 100644 src/pbHelpers/Component.ts create mode 100644 src/pbHelpers/ComponentType.tsx create mode 100644 src/pbHelpers/ComponentTypes/AirQuality.ts create mode 100644 src/pbHelpers/ComponentTypes/Airflow.ts create mode 100644 src/pbHelpers/ComponentTypes/AnalogInput.ts create mode 100644 src/pbHelpers/ComponentTypes/CO2.ts create mode 100644 src/pbHelpers/ComponentTypes/Calcium.ts create mode 100644 src/pbHelpers/ComponentTypes/Capacitance.ts create mode 100644 src/pbHelpers/ComponentTypes/CapacitorCable.ts create mode 100644 src/pbHelpers/ComponentTypes/Conductivity.ts create mode 100644 src/pbHelpers/ComponentTypes/DHT.ts create mode 100644 src/pbHelpers/ComponentTypes/DragerGasDongle.ts create mode 100644 src/pbHelpers/ComponentTypes/Ethylene.ts create mode 100644 src/pbHelpers/ComponentTypes/GPS.ts create mode 100644 src/pbHelpers/ComponentTypes/GrainCable.ts create mode 100644 src/pbHelpers/ComponentTypes/Invalid.ts create mode 100644 src/pbHelpers/ComponentTypes/Lidar.ts create mode 100644 src/pbHelpers/ComponentTypes/Light.ts create mode 100644 src/pbHelpers/ComponentTypes/Modem.ts create mode 100644 src/pbHelpers/ComponentTypes/Nitrate.ts create mode 100644 src/pbHelpers/ComponentTypes/O2.ts create mode 100644 src/pbHelpers/ComponentTypes/ORP.ts create mode 100644 src/pbHelpers/ComponentTypes/OnOffInput.ts create mode 100644 src/pbHelpers/ComponentTypes/OnOffOutput.ts create mode 100644 src/pbHelpers/ComponentTypes/PH.ts create mode 100644 src/pbHelpers/ComponentTypes/Potassium.ts create mode 100644 src/pbHelpers/ComponentTypes/Power.ts create mode 100644 src/pbHelpers/ComponentTypes/Pressure.ts create mode 100644 src/pbHelpers/ComponentTypes/PressureCable.ts create mode 100644 src/pbHelpers/ComponentTypes/Sen5x.ts create mode 100644 src/pbHelpers/ComponentTypes/StepperMotor.ts create mode 100644 src/pbHelpers/ComponentTypes/Temperature.ts create mode 100644 src/pbHelpers/ComponentTypes/Trigger.ts create mode 100644 src/pbHelpers/ComponentTypes/VPD.ts create mode 100644 src/pbHelpers/ComponentTypes/VibrationCable.ts create mode 100644 src/pbHelpers/ComponentTypes/Voltage.ts create mode 100644 src/pbHelpers/ComponentTypes/Weight.ts create mode 100644 src/pbHelpers/ComponentTypes/index.ts create mode 100644 src/pbHelpers/DeviceAvailability.ts create mode 100644 src/pbHelpers/Enums.ts create mode 100644 src/pbHelpers/Interaction.ts create mode 100644 src/pbHelpers/MeasurementDescriber.ts create mode 100644 src/providers/pond/componentAPI.tsx create mode 100644 src/providers/pond/interactionsAPI.tsx create mode 100644 src/services/cache.js create mode 100644 src/services/feature/service.ts create mode 100644 src/services/google/googleAPI.ts create mode 100644 src/services/google/mapHelpers.ts create mode 100644 src/services/restAPI.js create mode 100644 src/utils/download.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/numbers.ts create mode 100644 src/utils/random.ts create mode 100644 src/utils/units.ts diff --git a/package-lock.json b/package-lock.json index 665a3b9..d47e330 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,15 +9,18 @@ "version": "0.0.0", "dependencies": { "@auth0/auth0-react": "^2.2.4", + "@date-io/dayjs": "^3.2.0", + "@date-io/moment": "^3.2.0", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", "@mui/icons-material": "^6.1.6", "@mui/material": "^6.1.6", "@mui/styles": "^6.1.6", - "@mui/x-date-pickers": "^7.23.6", + "@mui/x-date-pickers": "^7.26.0", "@sentry/react": "^8.38.0", "@types/classnames": "^2.3.0", "axios": "^1.7.7", + "dayjs": "^1.11.13", "lodash": "^4.17.21", "moment": "^2.30.1", "moment-timezone": "^0.5.46", @@ -32,7 +35,10 @@ "react-infinite-scroller": "^1.2.6", "react-phone-input-2": "^2.15.1", "react-router-dom": "^6.28.0", - "react-virtualized-auto-sizer": "^1.0.25" + "react-virtualized-auto-sizer": "^1.0.25", + "recharts": "^2.15.1", + "save-svg-as-png": "^1.4.17", + "victory": "^37.3.6" }, "devDependencies": { "@babel/types": "^7.25.8", @@ -47,6 +53,7 @@ "eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.12", "globals": "^15.9.0", + "save-svg-as-png": "^1.4.17", "typescript": "^5.5.3", "typescript-eslint": "^8.7.0", "vite": "^5.4.8", @@ -528,6 +535,46 @@ "node": ">=6.9.0" } }, + "node_modules/@date-io/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@date-io/core/-/core-3.2.0.tgz", + "integrity": "sha512-hqwXvY8/YBsT9RwQITG868ZNb1MVFFkF7W1Ecv4P472j/ZWa7EFcgSmxy8PUElNVZfvhdvfv+a8j6NWJqOX5mA==", + "license": "MIT" + }, + "node_modules/@date-io/dayjs": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@date-io/dayjs/-/dayjs-3.2.0.tgz", + "integrity": "sha512-+3LV+3N+cpQbEtmrFo8odg07k02AFY7diHgbi2EKYYANOOCPkDYUjDr2ENiHuYNidTs3tZwzDKckZoVNN4NXxg==", + "license": "MIT", + "dependencies": { + "@date-io/core": "^3.2.0" + }, + "peerDependencies": { + "dayjs": "^1.8.17" + }, + "peerDependenciesMeta": { + "dayjs": { + "optional": true + } + } + }, + "node_modules/@date-io/moment": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@date-io/moment/-/moment-3.2.0.tgz", + "integrity": "sha512-/Tm+/VGvM0cOxvBeft2ZDw/xkdDRqBj2mxqGJz1TqbVpbDgfvq0tTCe/v3iSRJSHlY48lkWRO6LM/yoxBvUNpA==", + "license": "MIT", + "dependencies": { + "@date-io/core": "^3.2.0" + }, + "peerDependencies": { + "moment": "^2.24.0" + }, + "peerDependenciesMeta": { + "moment": { + "optional": true + } + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.12.0.tgz", @@ -1527,14 +1574,14 @@ } }, "node_modules/@mui/x-date-pickers": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.23.6.tgz", - "integrity": "sha512-jt6rEAYLju3NZe3y2S+I5KcTiSHV79FW0jeNUEUTceg1qsPzseHbND66k3zVF0hO3N2oZtLtPywof6vN5Doe+Q==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.26.0.tgz", + "integrity": "sha512-bhSDce1b5MBYYlCdHQJBThe10LGTE3D/u53TDQ41+IRj+iiNCun8jivw3DxKhmoBxlB+hVdkcltpTtIGlPjQZQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.7", "@mui/utils": "^5.16.6 || ^6.0.0", - "@mui/x-internals": "7.23.6", + "@mui/x-internals": "7.26.0", "@types/react-transition-group": "^4.4.11", "clsx": "^2.1.1", "prop-types": "^15.8.1", @@ -1593,9 +1640,9 @@ } }, "node_modules/@mui/x-internals": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.23.6.tgz", - "integrity": "sha512-hT1Pa4PNCnxwiauPbYMC3p4DiEF1x05Iu4C1MtC/jMJ1LtthymLmTuQ6ZQ53/R9FeqK6sYd6A6noR+vNMjp5DA==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.26.0.tgz", + "integrity": "sha512-VxTCYQcZ02d3190pdvys2TDg9pgbvewAVakEopiOgReKAUhLdRlgGJHcOA/eAuGLyK1YIo26A6Ow6ZKlSRLwMg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.7", @@ -2119,6 +2166,69 @@ "classnames": "*" } }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -2889,6 +2999,139 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-voronoi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.4.tgz", + "integrity": "sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==", + "license": "BSD-3-Clause" + }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -2905,6 +3148,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -2920,6 +3169,21 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/delaunator": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-4.0.1.tgz", + "integrity": "sha512-WNPWi1IRKZfCt/qIDMfERkDp93+iZEmOxN2yy4Jg+Xhv8SLk2UTqqbe1sfiipn0and9QrE914/ihdx82Y/Giag==", + "license": "ISC" + }, + "node_modules/delaunay-find": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/delaunay-find/-/delaunay-find-0.0.6.tgz", + "integrity": "sha512-1+almjfrnR7ZamBk0q3Nhg6lqSe6Le4vL0WJDSMx4IDbQwTpUTXPjxC00lqLBT8MYsJpPCbI16sIkw9cPsbi7Q==", + "license": "ISC", + "dependencies": { + "delaunator": "^4.0.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3262,12 +3526,27 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-equals": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", + "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -3557,6 +3836,15 @@ "node": ">=0.8.19" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -3668,6 +3956,12 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4363,6 +4657,12 @@ "react": ">=16.13.1" } }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, "node_modules/react-image": { "version": "4.1.0", "integrity": "sha512-qwPNlelQe9Zy14K2pGWSwoL+vHsAwmJKS6gkotekDgRpcnRuzXNap00GfibD3eEPYu3WCPlyIUUNzcyHOrLHjw==", @@ -4441,6 +4741,21 @@ "react-dom": ">=16.8" } }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -4475,6 +4790,60 @@ "lodash": "^4.0.1" } }, + "node_modules/recharts": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.1.tgz", + "integrity": "sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", @@ -4574,6 +4943,13 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/save-svg-as-png": { + "version": "1.4.17", + "resolved": "https://registry.npmjs.org/save-svg-as-png/-/save-svg-as-png-1.4.17.tgz", + "integrity": "sha512-7QDaqJsVhdFPwviCxkgHiGm9omeaMBe1VKbHySWU6oFB2LtnGCcYS13eVoslUgq6VZC6Tjq/HddBd1K6p2PGpA==", + "dev": true, + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -4692,6 +5068,12 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tiny-warning": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", @@ -4884,11 +5266,538 @@ "punycode": "^2.1.0" } }, + "node_modules/victory": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory/-/victory-37.3.6.tgz", + "integrity": "sha512-CZ1vjvra0R1U3T2dMI4EsjI8Ng+JmQ2ox/EweSzjkTnHfW/Vn5ylryadawDiYjDMcBvABjO3uODsIlSEm4d/Sw==", + "license": "MIT", + "dependencies": { + "victory-area": "37.3.6", + "victory-axis": "37.3.6", + "victory-bar": "37.3.6", + "victory-box-plot": "37.3.6", + "victory-brush-container": "37.3.6", + "victory-brush-line": "37.3.6", + "victory-candlestick": "37.3.6", + "victory-canvas": "37.3.6", + "victory-chart": "37.3.6", + "victory-core": "37.3.6", + "victory-create-container": "37.3.6", + "victory-cursor-container": "37.3.6", + "victory-errorbar": "37.3.6", + "victory-group": "37.3.6", + "victory-histogram": "37.3.6", + "victory-legend": "37.3.6", + "victory-line": "37.3.6", + "victory-pie": "37.3.6", + "victory-polar-axis": "37.3.6", + "victory-scatter": "37.3.6", + "victory-selection-container": "37.3.6", + "victory-shared-events": "37.3.6", + "victory-stack": "37.3.6", + "victory-tooltip": "37.3.6", + "victory-voronoi": "37.3.6", + "victory-voronoi-container": "37.3.6", + "victory-zoom-container": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-area": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-area/-/victory-area-37.3.6.tgz", + "integrity": "sha512-wVC8LKrZJLiSySNuJLRCB449qZTsPiRyzLlNoJwe21y+XA/a2HJbmJSeywmo8P153aX8viKe1H8ygDsTFXQhHw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6", + "victory-vendor": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-axis": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-axis/-/victory-axis-37.3.6.tgz", + "integrity": "sha512-Vi0dZvgmXmnCdoqc49WckeG5cMXnl7FTtqVhXu9JweA9cgCnkZabBd5mRvAjblb3Lo4j0HZCSPKHYWUPW70qZg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-bar": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-bar/-/victory-bar-37.3.6.tgz", + "integrity": "sha512-jdATFRWL1LUW/yEpKWx/aId2BiU2o1pPF9+Kh1TFISBduJoI4ZqvZD90H1QK4f/z50PikqiqiDECaKoKM1jfOQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6", + "victory-vendor": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-box-plot": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-box-plot/-/victory-box-plot-37.3.6.tgz", + "integrity": "sha512-GOucnD63h14ScBuISC/nd1GBTEx6gIZfLE+0P0gyeH1poBKq0trTTvpQDvAMuGR8zICfEETG3ltmUMCwRrFyUg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6", + "victory-vendor": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-brush-container": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-brush-container/-/victory-brush-container-37.3.6.tgz", + "integrity": "sha512-LfZ2CgX1cYAqCtYxcSB68OfZS2v0T2VLXoEArd0lCXfRBY1Gya7GacCUcuo7GoK9XOXeslx7S/U95aVutt1VLg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "react-fast-compare": "^3.2.0", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-brush-line": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-brush-line/-/victory-brush-line-37.3.6.tgz", + "integrity": "sha512-zsZJfF1fUj4F7mUoIMV+h73qoTClPA4bKM1terlYrDBD8l/c/f0KBbEotu3E1X+n4QMmDRruswaB/YUdqK5QLA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "react-fast-compare": "^3.2.0", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-candlestick": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-candlestick/-/victory-candlestick-37.3.6.tgz", + "integrity": "sha512-h/mOmkCrsWrirn4dFnpLxJPXpxT+uHxuYxnXGrAyH+YUOrVj3iKaDJlEiVlz5vy30syE5j5hzTQCMsZ/hzHNdg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-canvas": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-canvas/-/victory-canvas-37.3.6.tgz", + "integrity": "sha512-1CD4S0uZ92sUGGSIEQferEfSqd/z9EXw9G6zkzPIoJeTKFshpfqCjUkNRx9Iu9Upxt3fUpId8Qwl1YfchmbrFg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-bar": "37.3.6", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-chart": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-chart/-/victory-chart-37.3.6.tgz", + "integrity": "sha512-IkPo/W4AJ7bPu902TGER09OseR9ODm+FQAKfOBw4JsdEhZZ7BiG9zgd/25+x0r5EsTLu81CYGQVkBa+ZazcOlA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "react-fast-compare": "^3.2.0", + "victory-axis": "37.3.6", + "victory-core": "37.3.6", + "victory-polar-axis": "37.3.6", + "victory-shared-events": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-core": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-core/-/victory-core-37.3.6.tgz", + "integrity": "sha512-aFgO6KokxPbUCPznZP5UPhOdI22pMuwDXKDt6eoQOnkVim66Ia+K95TQar2nwVKGYV5j26aKVf/n9blwphGJRw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "react-fast-compare": "^3.2.0", + "victory-vendor": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-create-container": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-create-container/-/victory-create-container-37.3.6.tgz", + "integrity": "sha512-Uf5bFQvqUsXCjqpvBW4LhrdrHkM6dBqxYgub6FCsBb86f84xZQ3vY7jFkg/JfvF0oGKMoWXYYrYLC1sk+fcWVA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-brush-container": "37.3.6", + "victory-core": "37.3.6", + "victory-cursor-container": "37.3.6", + "victory-selection-container": "37.3.6", + "victory-voronoi-container": "37.3.6", + "victory-zoom-container": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-cursor-container": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-cursor-container/-/victory-cursor-container-37.3.6.tgz", + "integrity": "sha512-+Oiw57d5nE+iq8As8RvepknzmNtKq1Gsc50u1X3IRd4jXtX8zqZrgXGlVZ+BP/tkLsWnGYVjKulwKBf2oaEUuw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-errorbar": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-errorbar/-/victory-errorbar-37.3.6.tgz", + "integrity": "sha512-WGAv/qizOlfmwKv+Yfxr4q6pDgTfloNQwi3Z3M0h8povjMZt74tHYkvi/TASSRYr3zv5kjUqUJ28qAyGMWwryQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-group": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-group/-/victory-group-37.3.6.tgz", + "integrity": "sha512-kgy/Azl5BxwlJAV0KDPGypv35TMrOD1J2ZxnJW2Wyyq+e8i0GGBIv5MoBzou64BRsDlS9V0CYRIjnkHgrBpB5w==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "react-fast-compare": "^3.2.0", + "victory-core": "37.3.6", + "victory-shared-events": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-histogram": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-histogram/-/victory-histogram-37.3.6.tgz", + "integrity": "sha512-K4d43MpXHYnGCLEMzfRpJ+lCRRDKALPi/juxfMGVzBPzSMgjC8h9x6hKdxaejiTd/E04UdzNO7J24plL3Uz8rA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "react-fast-compare": "^3.2.0", + "victory-bar": "37.3.6", + "victory-core": "37.3.6", + "victory-vendor": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-legend": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-legend/-/victory-legend-37.3.6.tgz", + "integrity": "sha512-vRRrhj3/ENqKVLdaBMzEmR83N6BOjox1bthYT1eJjN2H5SIK35bxn30IkiV/Pz3y627EqZe4TAWaxc0jiJlCiA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-line": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-line/-/victory-line-37.3.6.tgz", + "integrity": "sha512-Ke817uf/qFbN9jU7Dba7CrcHXYO5wAZuKKnyeHJmLDeQeFST0773xejnIuC+dBgZipjFr4KIbSd+VcUafFNE1g==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6", + "victory-vendor": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-pie": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-pie/-/victory-pie-37.3.6.tgz", + "integrity": "sha512-tvdgAZ/HQWlo3KDDe0XAVbizHuaNMbgkkiF7zfA7Ww+3bHSs+0P9dsDtK2xP365D8gBCOv8pWmuzvKRhzNbqeA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6", + "victory-vendor": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-polar-axis": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-polar-axis/-/victory-polar-axis-37.3.6.tgz", + "integrity": "sha512-RpFsCkzHezJq5P+C/wtVdjEHX25JIFsSgs6qYSnfr/hayaFbWgK5HhRFpriQm5hg61cx47WxAOLyHvzf0nasvw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-scatter": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-scatter/-/victory-scatter-37.3.6.tgz", + "integrity": "sha512-fp95zMTPXgW1cmTowzDXhn+KxePMVDrzU0lotsHQMdBV7eB+ioXdu9hORlx4VHmMYg2ihsGwRTF+VAZ7rGxphA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-selection-container": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-selection-container/-/victory-selection-container-37.3.6.tgz", + "integrity": "sha512-gd3qODDlBtLEJM7+2jCXk2YcLBUmIpYEEHswytMhwc6zihxXipGBUHRulhLj/I05mKay2gaOAg5ewiJHd4Awgw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-shared-events": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-shared-events/-/victory-shared-events-37.3.6.tgz", + "integrity": "sha512-ygrbOtzLUTbtKebacZKyQRekhSAROnAvMkVI/PKsAGsz0ClY9P7qDEJG7eTUUygjO6ax0tI6WNE6JogQzeD1gw==", + "license": "MIT", + "dependencies": { + "json-stringify-safe": "^5.0.1", + "lodash": "^4.17.19", + "react-fast-compare": "^3.2.0", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-stack": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-stack/-/victory-stack-37.3.6.tgz", + "integrity": "sha512-ldod04RdqGJGH5p5eWXCofdTkbhZqIp3iwW7NpxSbMDLs8zPQIVvDFVtuJgMwQiC5vnIpbhMmxVeFbr8m64ZKA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "react-fast-compare": "^3.2.0", + "victory-core": "37.3.6", + "victory-shared-events": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-tooltip": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-tooltip/-/victory-tooltip-37.3.6.tgz", + "integrity": "sha512-vqaJS9noauOqDDBBAV9Ln9duOY/i17h1DCfCPAqhwPFyvFbwKvAub9zPTeYWAm/14VvWX5O/0yekFCVbcC7hjg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/victory-voronoi": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-voronoi/-/victory-voronoi-37.3.6.tgz", + "integrity": "sha512-Q+1FWHp8IAbmDL9pGWS0y0N4Cb5qmD9OOgxoxCfIDsLlhGvd6LddhRoknWsN7WnreaK+XiwjSfQkdMTCZ4hdhQ==", + "license": "MIT", + "dependencies": { + "d3-voronoi": "^1.1.4", + "lodash": "^4.17.19", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-voronoi-container": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-voronoi-container/-/victory-voronoi-container-37.3.6.tgz", + "integrity": "sha512-qAAG0rMuK7A4EoJ4cyUk5wNdOW+HuCXNKPOko+hYK6wWOYXJvFhiglYyA85a695YyAXECc6JyJS/crm4IOEFag==", + "license": "MIT", + "dependencies": { + "delaunay-find": "0.0.6", + "lodash": "^4.17.19", + "react-fast-compare": "^3.2.0", + "victory-core": "37.3.6", + "victory-tooltip": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-zoom-container": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-zoom-container/-/victory-zoom-container-37.3.6.tgz", + "integrity": "sha512-AGL+k20mI44OL5b0VgIxlmnNSefIoFmbbim5NraPmIxbtns9qQW/56ivIncJcYomBungIx99gUpsEpcQaMNHgQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, "node_modules/vite": { - "version": "5.4.9", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.9.tgz", - "integrity": "sha512-20OVpJHh0PAM0oSOELa5GaZNWeDjcAvQjGXy2Uyr+Tp+/D2/Hdz6NLgpJLsarPTA2QJ6v8mX2P1ZfbsSKvdMkg==", + "version": "5.4.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz", + "integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==", "dev": true, + "license": "MIT", "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/package.json b/package.json index 706984f..2c2c4cb 100644 --- a/package.json +++ b/package.json @@ -18,15 +18,18 @@ }, "dependencies": { "@auth0/auth0-react": "^2.2.4", + "@date-io/dayjs": "^3.2.0", + "@date-io/moment": "^3.2.0", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", "@mui/icons-material": "^6.1.6", "@mui/material": "^6.1.6", "@mui/styles": "^6.1.6", - "@mui/x-date-pickers": "^7.23.6", + "@mui/x-date-pickers": "^7.26.0", "@sentry/react": "^8.38.0", "@types/classnames": "^2.3.0", "axios": "^1.7.7", + "dayjs": "^1.11.13", "lodash": "^4.17.21", "moment": "^2.30.1", "moment-timezone": "^0.5.46", @@ -41,7 +44,10 @@ "react-infinite-scroller": "^1.2.6", "react-phone-input-2": "^2.15.1", "react-router-dom": "^6.28.0", - "react-virtualized-auto-sizer": "^1.0.25" + "react-virtualized-auto-sizer": "^1.0.25", + "recharts": "^2.15.1", + "save-svg-as-png": "^1.4.17", + "victory": "^37.3.6" }, "devDependencies": { "@babel/types": "^7.25.8", @@ -56,6 +62,7 @@ "eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.12", "globals": "^15.9.0", + "save-svg-as-png": "^1.4.17", "typescript": "^5.5.3", "typescript-eslint": "^8.7.0", "vite": "^5.4.8", diff --git a/src/charts/GrainDryingChart.tsx b/src/charts/GrainDryingChart.tsx new file mode 100644 index 0000000..f0b67c1 --- /dev/null +++ b/src/charts/GrainDryingChart.tsx @@ -0,0 +1,200 @@ +import { useTheme } from "@mui/material"; +import { blue, indigo, orange, red } from "@mui/material/colors"; +import MaterialChartTooltip from "charts/MaterialChartTooltip"; +import { usePrevious } from "hooks"; +import moment from "moment"; +import { useEffect, useState } from "react"; +import { + Area, + AreaChart, + Legend, + ReferenceArea, + ResponsiveContainer, + Tooltip, + TooltipProps, + XAxis, + YAxis +} from "recharts"; +import { roundTo } from "utils/numbers"; + +export interface GrainDryingPoint { + timestamp: number; + dryScore: number; +} + +interface Props { + data: GrainDryingPoint[]; + showStroke?: boolean; + useGradient?: boolean; + displayY?: boolean; + newXDomain?: number[] | string[]; + multiGraphZoom?: (domain: number[] | string[]) => void; + multiGraphZoomOut?: boolean; +} + +export default function GrainDryingChart(props: Props) { + const { data, showStroke, useGradient, displayY, newXDomain, multiGraphZoom } = props; + const prevData = usePrevious(data); + const theme = useTheme(); + const [offset, setOffset] = useState(0); + const now = moment(); + const veryDry = red[800]; + const dry = orange[500]; + const damp = blue[500]; + const veryDamp = indigo[800]; + const [xDomain, setXDomain] = useState(["dataMin", "dataMax"]); + const [refLeft, setRefLeft] = useState(); + const [refRight, setRefRight] = useState(); + + useEffect(() => { + if (newXDomain) { + setXDomain(newXDomain); + } + }, [newXDomain]); + + useEffect(() => { + const gradientOffset = () => { + const vpdMax = Math.max(...data.map(p => p.dryScore)); + const vpdMin = Math.min(...data.map(p => p.dryScore)); + + if (vpdMax <= 0) { + return 0; + } + if (vpdMin >= 0) { + return 1; + } + + return vpdMax / (vpdMax - vpdMin); + }; + + if (!prevData || prevData !== data) { + setOffset(gradientOffset()); + } + }, [data, prevData, now]); + + 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); + } + } + }; + + //just commenting this out as this charts zoom will likely never be controlled from inside + // const zoomOut = () => { + // setXDomain(["dataMin", "dataMax"]); + // }; + + if (data.length <= 1) return null; + return ( + + { + if (e) { + setRefLeft(e.activeLabel); + } + }} + onMouseMove={(e: any) => { + if (e) { + setRefRight(e.activeLabel); + } + }} + onMouseUp={() => { + setRefLeft(undefined); + setRefRight(undefined); + zoom(); + }}> + {displayY && ( + { + let val = vpd.toFixed(2); + return val; + }} + type="number" + // label={{ + // value: "Water Content", + // angle: -90, + // position: "insideLeft", + // fill: theme.palette.text.primary + // }} + domain={["auto", "auto"]} + tick={{ fill: theme.palette.text.primary }} + /> + )} + { + 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" + /> + + moment(timestamp).format("lll")} + content={(props: TooltipProps) => ( + + `${roundTo(parseFloat(String(score)), 2).toString()} ${ + score === 0 ? "" : score as number > 0 ? " (Drying)" : " (Hydrating)" + }` + } + /> + )} + /> + + + + + + + + + + + + + + {refLeft && refRight ? ( + + ) : null} + + + ); +} diff --git a/src/charts/GraphSettings.tsx b/src/charts/GraphSettings.tsx new file mode 100644 index 0000000..0b2b095 --- /dev/null +++ b/src/charts/GraphSettings.tsx @@ -0,0 +1,134 @@ +import { + Button, + DialogActions, + DialogContent, + DialogTitle, + Grid2 as Grid, + MenuItem, + TextField +} from "@mui/material"; +import ResponsiveDialog from "common/ResponsiveDialog"; +import { useComponentAPI, useSnackbar } from "hooks"; +import { Component } from "models"; +import { pond } from "protobuf-ts/pond"; +// import { MatchParams } from "navigation/Routes"; +// import { useRouteMatch } from "react-router"; +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; + +interface Props { + open: boolean; + newChart?: boolean; + closeDialog: () => void; + component: Component; + mutation?: pond.Mutator; + currentMin: number | string; + currentMax: number | string; + adjustMin: (val: number) => void; + adjustMax: (val: number) => void; +} + +export default function GraphSettings(props: Props) { + const { + open, + component, + mutation, + closeDialog, + adjustMin, + adjustMax, + currentMin, + currentMax, + newChart + } = props; + const [newMutation, setNewMutation] = useState(0); + const componentAPI = useComponentAPI(); + const deviceID = useParams<{ deviceID: string }>()?.deviceID ?? ""; + const { openSnack } = useSnackbar(); + + useEffect(() => { + if (mutation !== undefined) { + setNewMutation(mutation); + } + }, [mutation]); + + const saveMutation = () => { + let comp = component; + let defaultMutations = comp.settings.defaultMutations; + if (!defaultMutations.includes(newMutation) && newMutation !== pond.Mutator.MUTATOR_NONE) { + defaultMutations.push(newMutation); + } + componentAPI + .update(parseInt(deviceID), comp.settings) + .then(() => openSnack("Mutations Updated")); + closeDialog(); + }; + + const deleteDerivedChart = () => { + let comp = component; + let defaultMuts = comp.settings.defaultMutations; + if (mutation && defaultMuts.includes(mutation)) { + defaultMuts.splice(defaultMuts.indexOf(mutation)); + } + componentAPI + .update(parseInt(deviceID), comp.settings) + .then(() => openSnack("Mutations Updated")); + closeDialog(); + }; + + const handleChange = (event: any) => { + setNewMutation(event.target.value as pond.Mutator); + }; + + return ( + + Chart Settings + + {newChart && ( + + {/* TODO: change the menu item to be from the available mutations in the component settings (doesn't exist yet) */} + None + Grain Moisture(EMC) + CFM + + )} + {!newChart && ( + + + adjustMin(parseInt(e.target.value))} + /> + + + adjustMax(parseInt(e.target.value))} + /> + + + )} + + + {mutation !== undefined && mutation > pond.Mutator.MUTATOR_NONE && ( + + )} + + {mutation === pond.Mutator.MUTATOR_NONE && ( + + )} + + + ); +} diff --git a/src/charts/MaterialChartTooltip.tsx b/src/charts/MaterialChartTooltip.tsx new file mode 100644 index 0000000..8e5cc6b --- /dev/null +++ b/src/charts/MaterialChartTooltip.tsx @@ -0,0 +1,47 @@ +import { + Box, + Paper, + Typography +} from "@mui/material"; +import React from "react"; +import { TooltipProps } from "recharts"; + +interface Props extends TooltipProps { + valueFormatter?: ( + value: React.ReactText | string | number | readonly (string | number)[] + ) => string; +} +export default function MaterialChartTooltip(props: Props) { + const { active, label, payload, labelFormatter, valueFormatter } = props; + const formattedLabel = () => { + if (!label) return "Unknown"; + return labelFormatter ? labelFormatter(label, payload ?? []) : label; + }; + + const payloadContent = (p: any) => { + const { name, value, dataKey } = p; + //if (!value) return null; // this was preventing 0's from displaying correctly + return ( + + {p.payload.name ?? name ?? "Unknown"} + {": "} + {valueFormatter ? valueFormatter(value) : value.toString()} + + ); + }; + + if (active) { + return ( + + + {payload && payload.map(p => payloadContent(p))} + + {formattedLabel()} + + + + ); + } + + return null; +} diff --git a/src/charts/MeasurementsChart.tsx b/src/charts/MeasurementsChart.tsx new file mode 100644 index 0000000..1baeea6 --- /dev/null +++ b/src/charts/MeasurementsChart.tsx @@ -0,0 +1,410 @@ +import React, { useEffect, useState } from "react"; +import { extension, GraphFilters, showMultilineCable } from "pbHelpers/ComponentType"; +import { Component, Interaction } from "models"; +import { pond } from "protobuf-ts/pond"; +import { describeMeasurement } from "pbHelpers/MeasurementDescriber"; +import { quack } from "protobuf-ts/quack"; +import { GraphType } from "common/Graph"; +import AreaGraph, { AreaData } from "./measurementCharts/AreaGraph"; +import moment, { Moment } from "moment"; +import MultiLineGraph, { LineData } from "./measurementCharts/MultiLineGraph"; +import { ReferenceArea, ReferenceLine } from "recharts"; +import { UnitMeasurement } from "models/UnitMeasurement"; +import { + Box, + Button, + Card, + CardContent, + Checkbox, + FormControlLabel, + Grid2 as Grid, + IconButton, + Typography +} from "@mui/material"; +import GroupSettingsIcon from "@mui/icons-material/Settings"; +import { green } from "@mui/material/colors"; +import GraphSettings from "./GraphSettings"; +import TimeBar from "common/time/TimeBar"; +import { useMobile } from "hooks"; +import { useGlobalState } from "providers"; + +interface Props { + startDate: Moment; + endDate: Moment; + component: Component; + interactions: Interaction[]; + filters?: GraphFilters; + unitMeasurements: UnitMeasurement[]; + sampleLimit?: number; + updateDateRange: (start: Moment, end: Moment, live: boolean) => void; + showOriginal: boolean; + showAveraged: boolean; +} + +interface Range { + min: number | string; + max: number | string; +} + +export default function MeasurementsChart(props: Props) { + const { + component, + interactions, + filters, + unitMeasurements, + updateDateRange, + startDate, + endDate, + showOriginal, + showAveraged, + sampleLimit + } = props; + const [currentMutator, setCurrentMutator] = useState(); + const [graphSettingsDialog, setGraphSettingsDialog] = useState(false); + const [graphRanges, setGraphRanges] = useState([]); + const [rangeIndex, setRangeIndex] = useState(0); + const [currentMin, setCurrentMin] = useState("dataMin"); + const [currentMax, setCurrentMax] = useState("dataMax"); + const [newChart, setNewChart] = useState(false); + const [xDomain, setXDomain] = useState(["dataMin", "dataMax"]); + const [{ showErrors }, dispatch] = useGlobalState(); + const isMobile = useMobile(); + // const [zoomStart, setZoomStart] = useState(); + // const [zoomEnd, setZoomEnd] = useState(); + // const [zoomed, setZoomed] = useState(false); + + // const zoomOut = () => { + // setXDomain(["dataMin", "dataMax"]); + // setZoomed(false); + // }; + + const zoomIn = (domain: number[] | string[]) => { + let start; + let end; + if (domain[0]) { + start = moment(domain[0]); + } + if (domain[1]) { + //add 1 second to the end time to prevent the last measurement from being cut off due to the milliseconds on the time in the database + end = moment(domain[1]).add(1, "second"); + } + setXDomain(domain); + //setZoomed(true); + //console.log(sampleLimit); + //console.log(unitMeasurements); + //check if the sample limit was set and we have unit measurements + if (sampleLimit && unitMeasurements.length > 0) { + //if the number of measurements in the unit measurement is less than half of the sample limit it means there are no more measurements to get, + //so if it is bigger then will need to hit the backend to get a new set of measurements + if (unitMeasurements[0].values.length >= sampleLimit / 2) { + if (start && end) { + updateDateRange(start, end, false); + setXDomain(["dataMin", "dataMax"]); + } + } + } + }; + + useEffect(() => { + let ranges: Range[] = []; + for (let i = 0; i < unitMeasurements.length; i++) { + ranges.push({ + min: "dataMax", + max: "dataMin" + }); + } + setGraphRanges(ranges); + }, [unitMeasurements]); + + //builds the reference lines to show on the graph + const getInteractionLines = (measurementType: quack.MeasurementType) => { + let selectedInteractions: JSX.Element[] = []; + interactions.forEach(interaction => { + if (filters?.selectedInteractions?.includes(interaction.key())) { + interaction.settings.conditions.forEach((condition, j) => { + if (condition.measurementType === measurementType) { + let describer = describeMeasurement(condition.measurementType); + selectedInteractions.push( + + ); + } + }); + } + }); + return selectedInteractions; + }; + + //builds the overlays to show on the graph + const getOverlays = (measurementType: quack.MeasurementType) => { + let overlays: JSX.Element[] = []; + component.settings.overlays.forEach((overlay, i) => { + if ( + overlay.measurementType === measurementType && + filters?.selectedOverlays?.includes(i.toString()) + ) { + overlays.push( + + {/* could possibly show overlay message here */} + + ); + } + }); + return overlays; + }; + + const buildGraphs = ( + unitMeasurements: UnitMeasurement[], + component: Component, + filters?: GraphFilters + ) => { + let graphs: JSX.Element[] = []; + let hasData = false; + let noData: JSX.Element = ( + + + Not enough data to display + + + {"(Queried between " + + moment(startDate).calendar() + + " and " + + moment(endDate).calendar() + + ")"} + + + ); + let elem: JSX.Element; + let graph: JSX.Element = noData; + let numBasicGraphs = unitMeasurements.length - 1; + let ext = extension(component.type(), component.subType()); + + unitMeasurements.forEach((measurements, i) => { + let m = pond.UnitMeasurementsForComponent.fromObject(measurements); + let describer = describeMeasurement( + m.type, + component.settings.type, + component.settings.subtype + ); + let min: string | number = "dataMin"; + let max: string | number = "dataMax"; + if (graphRanges[i]) { + min = graphRanges[i].min; + max = graphRanges[i].max; + } + if (showMultilineCable(component.type(), filters)) { + let lineChartData = ext.lineChartData(m, component.settings.smoothingAverages, filters); + let lineData: LineData[] = lineChartData.lines; + let averagedData: LineData[] = lineChartData.average; + if (lineData.length > 0) { + hasData = true; + graph = ( + 1 ? m.values[0].values.length : 0} + describer={describer} + interactionLines={getInteractionLines(m.type)} + overlays={getOverlays(m.type)} + tooltip + newXDomain={xDomain} + multiGraphZoom={domain => { + zoomIn(domain); + }} + multiGraphZoomOut + /> + ); + } + } else { + switch (describer.graph()) { + case GraphType.AREA: + let chartData = ext.areaChartData(m, component.settings.smoothingAverages, filters); + let areaData: AreaData[] = chartData.area; + let avgData: AreaData[] = chartData.average; + if (areaData.length > 0) { + hasData = true; + graph = ( + { + zoomIn(domain); + }} + multiGraphZoomOut + /> + ); + } + break; + //for the moment not going to worry about these graphs and possibly just make them as needed + case GraphType.SCATTER: + break; + case GraphType.RADAR: + break; + case GraphType.BAR: + break; + default: + //line graph + let lineChartData = ext.lineChartData(m, component.settings.smoothingAverages, filters); + let lineData: LineData[] = lineChartData.lines; + let averagedData: LineData[] = lineChartData.average; + if (lineData.length > 0) { + hasData = true; + graph = ( + 1 ? m.values[0].values.length : 0} + describer={describer} + interactionLines={getInteractionLines(m.type)} + overlays={getOverlays(m.type)} + tooltip + newXDomain={xDomain} + multiGraphZoom={domain => { + zoomIn(domain); + }} + multiGraphZoomOut + /> + ); + } + break; + } + } + + elem = ( + + + + + {graph} + + { + if (i + 1 > numBasicGraphs) { + setCurrentMutator(component.settings.defaultMutations[i - numBasicGraphs]); + } else { + setCurrentMutator(undefined); + } + setGraphSettingsDialog(true); + setRangeIndex(i); + setNewChart(false); + if (graphRanges[i]) { + setCurrentMax(max); + setCurrentMin(min); + } + }}> + + + + + + + + ); + graphs.push(elem); + }); + return hasData ? graphs : noData; + }; + + return ( + + {!isMobile ? ( + + + + + + { + //setShowErrors(!showErrors); + dispatch({ key: "showErrors", value: !showErrors }); + }} + /> + } + /> + + {/* + {zoomed && ( + + )} + {zoomed && ( + + )} + */} + + ) : ( + + )} + + {buildGraphs(unitMeasurements, component, filters)} + + { + setGraphSettingsDialog(false); + }} + newChart={newChart} + component={component} + mutation={currentMutator} + currentMin={currentMin} + currentMax={currentMax} + adjustMin={val => { + graphRanges[rangeIndex].min = val; + }} + adjustMax={val => { + graphRanges[rangeIndex].max = val; + }} + /> + + ); +} diff --git a/src/charts/measurementCharts/AreaGraph.tsx b/src/charts/measurementCharts/AreaGraph.tsx new file mode 100644 index 0000000..9337fc1 --- /dev/null +++ b/src/charts/measurementCharts/AreaGraph.tsx @@ -0,0 +1,213 @@ +import { + Box, + Button, + useTheme +} from "@mui/material"; +import MaterialChartTooltip from "charts/MaterialChartTooltip"; +import moment from "moment"; +import { MeasurementDescriber } from "pbHelpers/MeasurementDescriber"; +import { useGlobalState } from "providers"; +import { useEffect, useState } from "react"; +import { + AreaChart, + Area, + XAxis, + ResponsiveContainer, + YAxis, + Tooltip, + TooltipProps, + ReferenceArea +} from "recharts"; +import { roundTo } from "utils"; + +export interface AreaData { + timestamp: number; + value: [number, number]; +} + +interface Props { + data: AreaData[]; + averagedData?: AreaData[]; + describer: MeasurementDescriber; + interactionLines?: JSX.Element[]; + overlays?: JSX.Element[]; + yMin?: number | string; + yMax?: number | string; + tooltip?: boolean; + newXDomain?: number[] | string[]; + multiGraphZoom?: (domain: number[] | string[]) => void; + multiGraphZoomOut?: boolean; + customHeight?: string | number; +} + +export default function AreaGraph(props: Props) { + const { + data, + describer, + interactionLines, + overlays, + yMin, + yMax, + tooltip, + newXDomain, + multiGraphZoom, + multiGraphZoomOut, + customHeight, + averagedData + } = props; + const theme = useTheme(); + const now = moment(); + const [refLeft, setRefLeft] = useState(); + const [refRight, setRefRight] = useState(); + const [xDomain, setXDomain] = useState(["dataMin", "dataMax"]); + // const [{ newStructure }] = useGlobalState(); + const [displayData, setDisplayData] = useState([]); + + useEffect(() => { + if (newXDomain) { + setXDomain(newXDomain); + } + }, [newXDomain]); + + useEffect(() => { + let d: any[] = []; + if (data) { + data.forEach(areaData => { + let pointSet: any = { + timestamp: areaData.timestamp + }; + pointSet[describer.label()] = areaData.value; + d.push(pointSet); + }); + } + if (averagedData) { + averagedData.forEach(areaData => { + let pointSet: any = { + timestamp: areaData.timestamp + }; + pointSet[describer.label() + "(avg)"] = areaData.value; + d.push(pointSet); + }); + } + setDisplayData(d); + }, [data, averagedData, describer]); + + 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); + } + } + }; + + const zoomOut = () => { + setXDomain(["dataMin", "dataMax"]); + }; + + return ( + + {!multiGraphZoomOut && ( + + )} + + { + if (e) { + setRefLeft(e.activeLabel); + } + }} + onMouseMove={(e: any) => { + if (e) { + setRefRight(e.activeLabel); + } + }} + onMouseUp={() => { + setRefLeft(undefined); + setRefRight(undefined); + zoom(); + }}> + + {tooltip && ( + moment(timestamp).format("lll")} + content={(props: TooltipProps) => { + return ( + { + let str = ""; + if (Array.isArray(value)) { + str = value[0].toFixed(1) + ", " + value[1].toFixed(1); + } else { + str = describer.convertWithoutUnits( + roundTo(parseFloat(String(value)), 2), + // newStructure + ); + } + return str + describer.GetUnit(); + }} + /> + ); + }} + /> + )} + { + 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} + {interactionLines} + {overlays} + + + + ); +} diff --git a/src/charts/measurementCharts/MultiLineGraph.tsx b/src/charts/measurementCharts/MultiLineGraph.tsx new file mode 100644 index 0000000..721d54f --- /dev/null +++ b/src/charts/measurementCharts/MultiLineGraph.tsx @@ -0,0 +1,259 @@ +import { Box, Button, useTheme } from "@material-ui/core"; +import MaterialChartTooltip from "charts/MaterialChartTooltip"; +import moment from "moment"; +import { MeasurementDescriber } from "pbHelpers/MeasurementDescriber"; +import { useGlobalState } from "providers"; +import React, { useEffect, useState } from "react"; +import { + Legend, + Line, + LineChart, + ReferenceArea, + ResponsiveContainer, + Tooltip, + TooltipProps, + XAxis, + YAxis +} from "recharts"; +import { roundTo } from "utils"; + +export interface LineData { + timestamp: number; + points: Point[]; +} + +export interface Point { + value: number; + node: number; +} + +interface Props { + lineData: LineData[]; + averagedData?: LineData[]; + numLines: number; + describer: MeasurementDescriber; + interactionLines?: JSX.Element[]; + overlays?: JSX.Element[]; + customHeight?: string | number; + tooltip?: boolean; + yMin?: number | string; + yMax?: number | string; + hideY?: boolean; + newXDomain?: number[] | string[]; + multiGraphZoom?: (domain: number[] | string[]) => void; + multiGraphZoomOut?: boolean; +} + +export default function MultiLineGraph(props: Props) { + const { + lineData, + numLines, + describer, + interactionLines, + overlays, + customHeight, + tooltip, + yMin, + yMax, + hideY, + newXDomain, + multiGraphZoom, + multiGraphZoomOut, + averagedData + } = props; + const [data, setData] = useState([]); + const theme = useTheme(); + const now = moment(); + const [refLeft, setRefLeft] = useState(); + const [refRight, setRefRight] = useState(); + const [xDomain, setXDomain] = useState(["dataMin", "dataMax"]); + const [{ newStructure }] = useGlobalState(); + + useEffect(() => { + if (newXDomain) { + setXDomain(newXDomain); + } + }, [newXDomain]); + + useEffect(() => { + let ld: any[] = []; + lineData.forEach(line => { + let lines = { + timestamp: line.timestamp + } as any; + line.points.forEach(point => { + let nodeLabel = (numLines > 1 ? "Node " + (point.node + 1) + " " : "") + describer.label(); + let nodeDetails = describer.nodeDetails(); + if (nodeDetails) { + nodeLabel = nodeDetails.labels[point.node]; + } + lines[nodeLabel] = point.value; + }); + ld.push(lines); + }); + if (averagedData) { + averagedData.forEach(line => { + let lines = { + timestamp: line.timestamp + } as any; + line.points.forEach(point => { + let nodeLabel = + (numLines > 1 ? "Node " + (point.node + 1) + " " : "") + describer.label() + "(avg)"; + let nodeDetails = describer.nodeDetails(); + if (nodeDetails) { + nodeLabel = nodeDetails.labels[point.node] + "(avg)"; + } + lines[nodeLabel] = point.value; + }); + ld.push(lines); + }); + } + setData(ld); + }, [lineData, describer, numLines, averagedData]); + + const buildLines = () => { + let lines: JSX.Element[] = []; + if (lineData.length > 0) { + lineData[0].points.forEach(point => { + let nodeLabel = (numLines > 1 ? "Node " + (point.node + 1) + " " : "") + describer.label(); + let nodeColour = describer.colour(); + let nodeDetails = describer.nodeDetails(); + if (nodeDetails) { + nodeLabel = nodeDetails.labels[point.node]; + nodeColour = nodeDetails.colours[point.node]; + } + lines.push( + + ); + }); + } + if (averagedData && averagedData.length > 0) { + averagedData[0].points.forEach(point => { + let nodeLabel = + (numLines > 1 ? "Node " + (point.node + 1) + " " : "") + describer.label() + "(avg)"; + let nodeDetails = describer.nodeDetails(); + if (nodeDetails) { + nodeLabel = nodeDetails.labels[point.node] + "(avg)"; + } + lines.push( + + ); + }); + } + return lines; + }; + + 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); + } + } + }; + + const zoomOut = () => { + setXDomain(["dataMin", "dataMax"]); + }; + + return ( + + {!multiGraphZoomOut && ( + + )} + + { + if (e) { + setRefLeft(e.activeLabel); + } + }} + onMouseMove={(e: any) => { + if (e) { + setRefRight(e.activeLabel); + } + }} + onMouseUp={() => { + setRefLeft(undefined); + setRefRight(undefined); + zoom(); + }}> + {describer.nodeDetails() && } + {tooltip && ( + moment(timestamp).format("lll")} + content={(props: TooltipProps) => { + return ( + { + return describer.convertWithUnits( + roundTo(parseFloat(String(value)), 2), + newStructure + ); + }} + /> + ); + }} + /> + )} + + { + 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} + {buildLines()} + {interactionLines} + {overlays} + + + + ); +} diff --git a/src/common/EventBlocker.tsx b/src/common/EventBlocker.tsx new file mode 100644 index 0000000..eee2058 --- /dev/null +++ b/src/common/EventBlocker.tsx @@ -0,0 +1,36 @@ +import { Box, BoxProps } from "@mui/material"; +import React from "react"; + +export const EventBlocker: React.FunctionComponent = props => { + const { children } = props; + + const stopPropagation = (event: any) => { + if (event.stopPropagation) { + event.stopPropagation(); + } + }; + + return ( + + {children} + + ); +}; + +export default EventBlocker; diff --git a/src/common/Graph.tsx b/src/common/Graph.tsx new file mode 100644 index 0000000..3d54b98 --- /dev/null +++ b/src/common/Graph.tsx @@ -0,0 +1,855 @@ +import React from "react"; +// import { darken, withTheme, WithTheme } from "@material-ui/core/styles"; +// import withWidth, { WithWidth } from "@material-ui/core/withWidth"; +import { Range } from "common/RangeInput"; +import moment from "moment"; +// import { RouteComponentProps, withRouter } from "react-router"; +import AutoSizer from "react-virtualized-auto-sizer"; +import { abbreviateNum } from "utils/numbers"; +import { or } from "utils/types"; +import { + createContainer, + ScalePropType, + VictoryArea, + VictoryAxis, + VictoryBar, + VictoryChart, + VictoryLabel, + VictoryLegend, + VictoryLine, + VictoryPie, + VictoryScatter, + VictoryStringOrNumberCallback, + VictoryTheme, + VictoryTooltip, + VictoryTooltipProps +} from "victory"; +import { + ResponsiveContainer, + RadarChart, + PolarGrid, + PolarAngleAxis, + PolarRadiusAxis, + Radar, + Legend +} from "recharts"; +import { pond } from "protobuf-ts/pond"; +import { styled, WithTheme } from "@mui/styles"; +import { getTextPrimary, getTextSecondary } from "theme/text"; +import { darken, useMediaQuery, useTheme, withTheme } from "@mui/material"; +import { useWidth } from "hooks"; +import { useNavigate } from "react-router-dom"; +const VictoryCursorVoronoiContainer: any = createContainer("cursor", "voronoi"); + +export enum GraphOrientation { + GRAPH_ORIENTATION_PORTRAIT = 0, + GRAPH_ORIENTATION_LANDSCAPE = 1 +} + +export enum GraphType { + LINEAR = 0, + AREA = 1, + BAR = 2, + MULTILINE = 3, + SCATTER = 4, + PIE = 5, + RADAR = 6 +} + +export interface vXAxis { + scale: ScalePropType; + label: string; +} + +export interface GraphPoint { + x: any; + y: any; + y0?: any; + bubble?: number; + colour?: string; +} + +export interface PieSlice { + x: string; + y: number; + fill: string; + linkToOnClick?: string; +} + +export interface InteractionLine { + value: any; + colour: any; +} + +export interface GraphComponent { + label: string; + data: Array | Array> | Array; + overlays?: pond.ComponentOverlays[]; + tick?: string; + colour: any; + type: GraphType; + range?: Range; + interactionLines?: Array; + isBoolean?: boolean; + states?: Array; + isPercent?: boolean; + xAxis?: vXAxis; + decimals?: number; + horizontalBars?: boolean; +} + +interface CustomVictoryTooltipProps extends VictoryTooltipProps { + height: any; + width: any; +} + +class CustomVictoryTooltip extends React.Component { + render() { + const { x, height, width, text } = this.props; + let y = height / 2; + let orientation: "right" | "left" = x && x < width * 0.66 ? "right" : "left"; + let xOffset: number = orientation === "right" ? 5 : -5; + return ( + + ); + } +} + +interface Props /*extends WithWidth, RouteComponentProps, WithTheme*/ { + orientation?: GraphOrientation; + graphComponent?: GraphComponent; +} + +interface State { + interactionComponents: Array; + dataComponent: any; + updateCounter: number; + overlayComponents: Array; +} + +//a linear graph with the option for two y-axis +class Graph extends React.Component { + constructor(props: Props) { + super(props); + this.state = { + interactionComponents: [], + dataComponent: null, + updateCounter: 0, + overlayComponents: [] + }; + } + + componentDidMount = () => { + this.setupGraph(); + }; + + componentDidUpdate = (prevProps: Props) => { + if (prevProps.graphComponent !== this.props.graphComponent) { + this.setupGraph(); + this.setState(prevState => ({ + updateCounter: prevState.updateCounter + 1 + })); + } + }; + + setupGraph = () => { + const { graphComponent } = this.props; + if (graphComponent && or(graphComponent.data, []).length > 0) { + let dataComponent = this.getDataComponent(graphComponent, "graphComponent"); + switch (graphComponent.type) { + case GraphType.PIE: + this.setState({ + interactionComponents: [], + dataComponent: dataComponent + }); + break; + case GraphType.RADAR: + this.setState({ + interactionComponents: [], + dataComponent: dataComponent + }); + break; + default: + let timeData, startDate: string, endDate: string; + timeData = + graphComponent.type === GraphType.MULTILINE + ? (graphComponent.data[0] as Array) + : (graphComponent.data as Array); + + startDate = timeData[timeData.length - 1].x; + endDate = timeData[0].x; + timeData.forEach((d: GraphPoint) => { + if (moment(d.x).isBefore(moment(startDate))) { + startDate = d.x; + } + if (moment(d.x).isAfter(moment(endDate))) { + endDate = d.x; + } + }); + + this.setState({ + interactionComponents: this.getInteractionComponents( + graphComponent.interactionLines ? graphComponent.interactionLines : [], + startDate, + endDate + ), + dataComponent: dataComponent, + overlayComponents: this.getOverlayComponents(startDate, endDate) + }); + + break; + } + } + }; + + getLegend = () => { + const { graphComponent } = this.props; + const textColour = getTextSecondary() + + if (!graphComponent) { + return []; + } + //prepares the initial object + var legend = [ + { + name: graphComponent.label, + symbol: { fill: graphComponent.colour }, + labels: { fill: textColour } + } + ]; + + return legend; + }; + + //uses the material theme from victory as a base + customTheme = () => { + let graphTheme = VictoryTheme.material as any; //need to cast as 'any' because Victory has an issue with their ThemeInterface + + //removing the grid + graphTheme.axis.style.grid = { + fill: "none", + stroke: "none" + }; + + graphTheme.voronoi.style.flyout = { + ...graphTheme.voronoi.style.flyout, + ...{ fill: "#323232", stroke: "none", strokeWidth: 0 } + }; + + graphTheme.voronoi.style.labels = { + ...graphTheme.voronoi.style.labels, + ...{ fill: "#fff" } + }; + + return graphTheme; + }; + + getInteractionData = (interactionLine: InteractionLine, startDate: any, endDate: any) => { + return [ + { x: startDate, y: interactionLine.value }, + { x: endDate, y: interactionLine.value } + ]; + }; + + getInteractionComponents = ( + interactionLines: Array, + startDate: any, + endDate: any + ) => { + var interactionComponents = []; + for (let i = 0; i < or(interactionLines, []).length; i++) { + interactionComponents.push( + + ); + } + + return interactionComponents; + }; + + getOverlayData = (overlay: pond.ComponentOverlays, startDate: any, endDate: any) => { + return [ + { x: startDate, y: overlay.max, y0: overlay.min }, + { x: endDate, y: overlay.max, y0: overlay.min } + ]; + }; + + getOverlayComponents = (startDate: any, endDate: any) => { + var overlayComponents: JSX.Element[] = []; + if (this.props.graphComponent?.overlays) { + let o = this.props.graphComponent?.overlays; + o.forEach((overlay, i) => { + overlayComponents.push( + + ); + }); + } + return overlayComponents; + }; + + getAreaComponent = (data: Array, colour: any, key: string) => { + return ( + + ); + }; + + // getBarComponent = ( + // data: Array, + // colour: any, + // key: string, + // horizontal: boolean = false + // ) => { + // return ( + // or(d.colour, colour), + // stroke: (d: GraphPoint) => or(d.colour, colour), + // fillOpacity: 0.6, + // strokeWidth: 1 + // } + // }} + // alignment="start" + // barWidth={7} + // cornerRadius={0} + // data={data} + // x="x" + // y="y" + // /> + // ); + // }; + + //data should contain an array of arrays (lines) + getMultilineComponent = (data: Array>, colour: any, key: string) => { + let lines = []; + for (var i = 0; i < or(data, []).length; i++) { + lines.push(this.getLineComponent(data[i], colour, key + "-" + i.toString())); + } + return lines; + }; + + getScatterComponent = (data: Array, colour: any, key: string) => { + // const { theme } = this.props; + return ( + datum.bubble * 1.5 + 5} + style={{ + data: { + fill: colour, + fillOpacity: (datum: any) => (datum.bubble > 0 ? 0.65 : 0.25), + stroke: darken(colour, 0.25), + strokeOpacity: (datum: any) => (datum.bubble > 0 ? 0.65 : 0.25), + strokeWidth: 1 + }, + labels: { + fill: getTextPrimary(), + fontSize: 10 + } + }} + labels={(datum: any) => (datum.bubble > 0 ? datum.bubble : "")} + labelComponent={} + data={data} + x="x" + y="y" + /> + ); + }; + + getRadarComponent = (data: Array, colour: any, key: string) => { + let dataTemplate = this.getWindDirTemplate(); + return ( + + + + + + + + ); + }; + + assembleRadarData = ( + data: Array, + dataTemplate: Array<{ key: string; value: number }> + ) => { + let aData = dataTemplate; + data.forEach(point => { + if (point.y !== 0) { + aData[point.y - 1].value = aData[point.y - 1].value + 1; + } + }); + return aData; + }; + + getWindDirTemplate = () => { + return [ + { + key: "N", + value: 0 + }, + { + key: "NNE", + value: 0 + }, + { + key: "NE", + value: 0 + }, + { + key: "ENE", + value: 0 + }, + { + key: "E", + value: 0 + }, + { + key: "ESE", + value: 0 + }, + { + key: "SE", + value: 0 + }, + { + key: "SSE", + value: 0 + }, + { + key: "S", + value: 0 + }, + { + key: "SSW", + value: 0 + }, + { + key: "SW", + value: 0 + }, + { + key: "WSW", + value: 0 + }, + { + key: "W", + value: 0 + }, + { + key: "WNW", + value: 0 + }, + { + key: "NW", + value: 0 + }, + { + key: "NNW", + value: 0 + } + ]; + }; + + getLineComponent = (data: Array, colour: any, key: string) => { + return ( + + ); + }; + + //dynamically loads a data component based on type (area, bar, line), defaults to line*/ + getDataComponent(graphComponent: GraphComponent, key: string): any { + const { type, data, colour } = graphComponent; + switch (type) { + case GraphType.AREA: + return this.getAreaComponent(data as Array, colour, key); + // case GraphType.BAR: + // return this.getBarComponent( + // data as Array, + // colour, + // key, + // graphComponent.horizontalBars + // ); + case GraphType.MULTILINE: + return this.getMultilineComponent(data as Array>, colour, key); + case GraphType.SCATTER: + return this.getScatterComponent(data as Array, colour, key); + case GraphType.PIE: + return; + case GraphType.RADAR: + return this.getRadarComponent(data as Array, colour, key); + default: + //line component is default + return this.getLineComponent(data as Array, colour, key); + } + } + + getContainerHeight = () => { + const { orientation } = this.props; + const width = useWidth() + if (orientation === GraphOrientation.GRAPH_ORIENTATION_LANDSCAPE) { + switch (width) { + case "xs": + return 250; + case "sm": + return 300; + case "md": + return 350; + case "lg": + return 375; + case "xl": + return 400; + default: + return 300; + } + } else { + //default portrait view + return 300; + } + }; + + emptyPieSlice(): PieSlice { + // const { theme } = this.props; + const theme = useTheme() + return { + x: "", + y: 1, + fill: theme.palette.grey.A700 + } as PieSlice; + } + + // fillCallback: VictoryStringOrNumberCallback = (d: PieSlice) => { + // return d.fill + // } + + getPieChart(width: any, height: any) { + const { graphComponent } = this.props; + const theme = useTheme() + const navigate = useNavigate() + const { data } = graphComponent ? graphComponent : { data: [] }; + const { label } = graphComponent ? graphComponent : { label: "" }; + const legendData = (data as PieSlice[]).map(d => { + return { + name: d.x + " (" + d.y + ")", + symbol: { fill: d.fill }, + labels: { fill: theme.palette.text.primary, fontSize: width >= 300 ? 16 : 14 } + }; + }); + + //filter out zero-slices + const filteredData: PieSlice[] = (data as PieSlice[]).filter(d => d.y > 0); + let hasActions = false; + filteredData.forEach(d => { + if (d.linkToOnClick) hasActions = true; + }); + + //pie chart is empty + if (filteredData.length <= 0) { + filteredData.push(this.emptyPieSlice()); + } + + return ( + + = 600 ? 3 : width >= 300 ? 2 : 1} + data={legendData} + /> + { + // return (d.fill) + // }, + cursor: hasActions ? "pointer" : "default" + }, + labels: { + fontSize: "16px", + fill: theme.palette.text.primary + } + }} + height={height} + width={width} + padAngle={2} + innerRadius={height * 0.25} + padding={{ + top: height * 0.25, + right: 0, + bottom: height * 0.1, + left: 0 + }} + labels={() => ""} + events={[ + { + target: "data", + eventHandlers: { + onClick: () => ({ + mutation: (props: any) => { + // const { history } = this.props; + const { datum } = props; + + if (datum.linkToOnClick) { + navigate(datum.linkToOnClick); + } + } + }), + onMouseOver: () => ({ + mutation: props => ({ + radius: hasActions ? props.radius * 1.05 : props.radius + }) + }), + onMouseOut: () => ({ + mutation: () => null + }) + } + } + ]} + /> + {label && label !== "" && ( + + )} + + ); + } + + render() { + const theme = useTheme() + const width = useWidth() + const { graphComponent } = this.props; + const { interactionComponents, overlayComponents, dataComponent, updateCounter } = this.state; + + if (!graphComponent || !graphComponent.data) { + return null; + } + + const { range, isBoolean, isPercent } = graphComponent; + const textColour = theme.palette.text.secondary; + const legend = this.getLegend(); + const height = this.getContainerHeight(); + + const hideCursor: boolean = [GraphType.BAR].includes(graphComponent.type); + const disableCursorVoronoi: boolean = [GraphType.AREA, GraphType.MULTILINE].includes( + graphComponent.type + ); + + return ( + + {({ width }: any) => ( + + {/* Don't need the entire graph for pie charts */} + {graphComponent.type !== GraphType.PIE ? ( + + {/* + // @ts-ignore */} + {graphComponent.type === GraphType.RADAR ? ( + + {dataComponent} + + ) : ( + + ) : ( + + ) + } + labels={(datum: any) => { + if ( + graphComponent && + or(graphComponent.xAxis, { scale: "time" } as vXAxis).scale === + "time" + ) { + let decimals = graphComponent.decimals ? graphComponent.decimals : 0; + return `${moment(or(datum.x, undefined)).calendar()}, ${datum.y.toFixed( + decimals + )}`; + } + return datum.y; + }} + labelComponent={ + hideCursor ? ( + + ) : ( + + ) + } + style={{ touchAction: "auto" }} + voronoiBlacklist={["interactionLine"]} + /> + } + animate={{ duration: 1000, easing: "expInOut", onLoad: { duration: 500 } }} + scale={{ + x: or(graphComponent.xAxis, { scale: "time" }).scale, + y: "linear" + }} + width={width} + height={height} + padding={{ top: 30, bottom: 30, left: 60, right: 60 }} + // @ts-ignore */} + minDomain={{ + y: + range && range.min !== undefined + ? range.min + : isBoolean || isPercent + ? 0 + : undefined + }} + maxDomain={{ + y: + range && range.max !== undefined + ? range.max + : isPercent + ? 100 + : isBoolean + ? 1 + : undefined + }} + domainPadding={isBoolean || isPercent ? undefined : { y: 50 }}> + + + {/* X Axis */} + + {/* Y Axis */} + + graphComponent.isBoolean + ? tick === 1 + ? or(graphComponent.states, ["Off", "On"])[1] + : or(graphComponent.states, ["Off", "On"])[0] + : `${abbreviateNum(tick)}` + or(graphComponent.tick, "") + } + /> + {interactionComponents} + {overlayComponents} + {dataComponent} + + )} + + ) : ( + this.getPieChart(width, height) + )} + + )} + + ); + } +} + +export default ((Graph)); diff --git a/src/common/RangeInput.tsx b/src/common/RangeInput.tsx new file mode 100644 index 0000000..50bdd29 --- /dev/null +++ b/src/common/RangeInput.tsx @@ -0,0 +1,79 @@ +import { + FormControl, + FormLabel, + Grid2 as Grid, + TextField +} from "@mui/material"; +// import { Theme } from "@material-ui/core/styles/createMuiTheme"; +// import withStyles, { WithStyles } from "@material-ui/core/styles/withStyles"; +import { Theme } from "@mui/material"; +import { withStyles, WithStyles } from "@mui/styles"; +import React from "react"; + +const styles = (theme: Theme) => { + return ({ + borderedContainer: { + padding: theme.spacing(1), + marginBottom: theme.spacing(2), + border: "1px solid rgba(255, 255, 255, 0.12)", + borderRadius: "4px" + } + }); +} +export interface Range { + min?: number; + max?: number; +} + +interface Props extends WithStyles { + range: Range; + onChange: (newRange: Range) => void; + label: string; + disabled?: boolean; +} + +interface State {} + +class RangeInput extends React.Component { + handleChange = (field: "min" | "max", value: string) => { + const { range, onChange } = this.props; + + let updatedRange = range; + updatedRange[field] = value && !isNaN(parseFloat(value)) ? parseFloat(value) : undefined; + onChange(updatedRange); + }; + + render() { + const { classes, range, label, disabled } = this.props; + + return ( + + {label} + + + this.handleChange("min", event.target.value)} + /> + + + this.handleChange("max", event.target.value)} + /> + + + + ); + } +} + +export default withStyles(styles)(RangeInput); diff --git a/src/component/AddComponentManualDialog.tsx b/src/component/AddComponentManualDialog.tsx new file mode 100644 index 0000000..982281d --- /dev/null +++ b/src/component/AddComponentManualDialog.tsx @@ -0,0 +1,98 @@ +import { Dialog, DialogActions, DialogContent, DialogTitle, Grid2, TextField } from "@mui/material"; +import CancelSubmit from "common/CancelSubmit"; +import { useComponentAPI, useSnackbar } from "hooks"; +import { pond } from "protobuf-ts/pond"; +import { useState } from "react"; +import { or } from "utils"; + +interface Props { + open: boolean; + onClose: () => void; + device: number; +} + +export default function AddComponentManualDialog (props: Props) { + const { open, onClose, device } = props; + const componentAPI = useComponentAPI(); + const snackbar = useSnackbar(); + const [component, setComponent] = useState(pond.ComponentSettings.create()) + + const onSubmit = () => { + componentAPI.add(device, component).then(() => { + snackbar.success("Component added") + }) + } + + const close = () => { + setComponent(pond.ComponentSettings.create()) + onClose() + } + + return ( + + + Manual Component Entry + + + + + { + let newSettings = pond.ComponentSettings.create(component) + newSettings.name = event.currentTarget.value + setComponent(newSettings) + }} + /> + + + + { + let newSettings = pond.ComponentSettings.create(component) + newSettings.type = or(parseInt(event.currentTarget.value), 0) + setComponent(newSettings) + }} + /> + + + { + let newSettings = pond.ComponentSettings.create(component) + newSettings.addressType = or(parseInt(event.currentTarget.value), 0) + setComponent(newSettings) + }} + /> + + + { + let newSettings = pond.ComponentSettings.create(component) + newSettings.address = or(parseInt(event.currentTarget.value), 0) + setComponent(newSettings) + }} + /> + + + + + + + + + ) +} \ No newline at end of file diff --git a/src/component/AddCompsFromDiag.tsx b/src/component/AddCompsFromDiag.tsx new file mode 100644 index 0000000..38940f3 --- /dev/null +++ b/src/component/AddCompsFromDiag.tsx @@ -0,0 +1,360 @@ +import { + Button, + DialogActions, + DialogContent, + DialogTitle, + makeStyles, + Step, + StepLabel, + Stepper, + createStyles, + Grid, + FormControlLabel, + Switch, + Table, + TableHead, + TableRow, + TableCell, + TableBody, + Box, + Typography, + Checkbox +} from "@material-ui/core"; + +import ResponsiveDialog from "common/ResponsiveDialog"; +import { Component, Device } from "models"; +import { pond, quack } from "protobuf-ts/pond"; +import { useComponentAPI, useSnackbar } from "providers"; +import React, { useEffect, useState } from "react"; +import { getThemeType } from "theme"; +import { CableInfo } from "./ComponentDiagnostics"; +import ComponentForm from "./ComponentForm"; + +const useStyles = makeStyles(() => + createStyles({ + buttons: { + marginLeft: 5, + marginRight: 5 + }, + cell: { + padding: 5 + }, + dark: { + backgroundColor: getThemeType() === "light" ? "rgb(245, 245, 245)" : "rgb(50, 50, 50)", + padding: 0 + }, + light: { + backgroundColor: getThemeType() === "light" ? "rgb(235, 235, 235)" : "rgb(60, 60, 60)", + padding: 0 + } + }) +); + +interface Props { + open: boolean; + closeDialog: () => void; + port: string; + diagComponent: Component; + device: Device; + cableInfo: CableInfo[]; + refreshCallback: () => void; +} + +interface CompStep { + label: string; + completed?: boolean; +} + +export default function AddCompsFromDiag(props: Props) { + const { diagComponent, port, cableInfo, open, closeDialog, device, refreshCallback } = props; + const [steps, setSteps] = useState([]); + const classes = useStyles(); + const [currentStep, setCurrentStep] = useState(0); + const [components, setComponents] = useState([]); + const { error, success } = useSnackbar(); + //const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false) + const compAPI = useComponentAPI(); + const [componentsToAdd, setComponentsToAdd] = useState([]); + const [settingsValid, setSettingsValid] = useState(false); + const [useAdvanced, setUseAdvanced] = useState(false); + + useEffect(() => { + let components: Component[] = []; + let steps: CompStep[] = []; + cableInfo.forEach(cable => { + let settings: pond.ComponentSettings = pond.ComponentSettings.create(); + settings.type = quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE; + settings.subtype = cable.subtype ?? quack.GrainCableSubtype.GRAIN_CABLE_SUBTYPE_NONE; + settings.addressType = cable.id + 8; + settings.address = diagComponent.settings.address; + settings.measurementPeriodMs = 3600000; //set the measurement/report period to 1 hour + settings.reportPeriodMs = 3600000; + settings.name = cable.name; + components.push(Component.create(pond.Component.create({ settings: settings }))); + steps.push({ label: "Cable ID: " + cable.id }); + }); + setComponents(components); + setSteps(steps); + }, [cableInfo, diagComponent]); + + const stepper = () => { + return ( + + {steps.map(compStep => { + const labelProps: { + optional?: React.ReactNode; + } = {}; + return ( + + {compStep.label} + + ); + })} + + ); + }; + + const close = () => { + closeDialog(); + setUseAdvanced(false); + setComponentsToAdd([]); + }; + + const addComponents = () => { + let c: pond.MultiComponentSettings = pond.MultiComponentSettings.create(); + componentsToAdd.forEach(component => { + c.components.push(component.settings); + }); + compAPI + .addMultiComponents(device.id(), c) + .then(resp => { + success("Components added to Device"); + }) + .catch(err => { + error("One or more component failed to add"); + }) + .finally(() => { + refreshCallback(); + }); + }; + + const resetSteps = () => { + steps.forEach(step => { + step.completed = false; + }); + }; + + const simpleActions = () => { + return ( + + + + + + + + + + + ); + }; + + const advancedActions = () => { + return ( + + + + + + + {currentStep !== 0 && ( + + )} + {currentStep !== steps.length - 1 && ( + + + + )} + {steps[currentStep] && !steps[currentStep].completed && ( + + )} + + + + + ); + }; + + const simpleContent = () => { + return ( + + + Select the Components to add to the device + Components will have their measurement interval set to 1 hour + + + + + + Add Component + + + Cable ID + + + Component Name + + + + + {components.map((comp, i) => ( + + + { + if (checked) { + let c = componentsToAdd; + c.push(comp); + setComponentsToAdd([...c]); + } else { + let c = componentsToAdd; + if (c.includes(comp)) { + c.splice(c.indexOf(comp), 1); + } + } + }} + /> + + + {comp.settings.addressType - 8} + + + {comp.name()} + + + ))} + +
+
+ ); + }; + + const advancedContent = () => { + return ( + + {stepper()} + { + components[currentStep].settings = component.settings; + setSettingsValid(isValid); + }} + /> + + ); + }; + + const componentsDialog = () => { + return ( + { + close(); + }}> + + + Port {port} Components + + { + setUseAdvanced(!useAdvanced); + setComponentsToAdd([]); + setCurrentStep(0); + resetSteps(); + }} + /> + } + label="Advanced" + labelPlacement="start" + /> + + + + {useAdvanced ? advancedContent() : simpleContent()} + {useAdvanced ? advancedActions() : simpleActions()} + + ); + }; + + return {componentsDialog()}; +} diff --git a/src/component/ComponentActions.tsx b/src/component/ComponentActions.tsx new file mode 100644 index 0000000..e5e467c --- /dev/null +++ b/src/component/ComponentActions.tsx @@ -0,0 +1,213 @@ +import { + createStyles, + Divider, + IconButton, + ListItemIcon, + ListItemText, + makeStyles, + Menu, + MenuItem, + Tooltip +} from "@material-ui/core"; +import { green, teal } from "@material-ui/core/colors"; +import { Theme } from "@material-ui/core/styles/createMuiTheme"; +import { AddCircle, DeleteOutline, MoreVert, SaveAlt, Settings } from "@material-ui/icons"; +import ExportDataSettings from "component/ExportDataSettings"; +import InteractionSettings from "interactions/InteractionSettings"; +import { Component, Device } from "models"; +import { Moment } from "moment"; +import { isController, isSource } from "pbHelpers/ComponentType"; +import { DeviceAvailabilityMap, OffsetAvailabilityMap } from "pbHelpers/DeviceAvailability"; +import { pond } from "protobuf-ts/pond"; +import { useGlobalState } from "providers"; +import React, { useState } from "react"; +import { or } from "utils/types"; +import ComponentSettings from "./ComponentSettings"; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + addIcon: { + color: green["500"], + "&:hover": { + color: green["600"] + } + }, + copyIcon: { + color: teal["500"], + "&:hover": { + color: teal["600"] + } + }, + exportDataIcon: { + color: green["300"], + "&:hover": { + color: green["400"] + } + }, + red: { + color: "var(--status-alert)" + } + }) +); + +interface Props { + device: Device; + component: Component; + components: Component[]; + availablePositions: DeviceAvailabilityMap; + availableOffsets: OffsetAvailabilityMap; + permissions: Array; + refreshCallback: () => void; + initialStartDate?: Moment; + initialEndDate?: Moment; + deviceComponentPreferences?: pond.DeviceComponentPreferences; +} + +export default function ComponentActions(props: Props) { + const classes = useStyles(); + const { + device, + component, + components, + availablePositions, + availableOffsets, + permissions, + refreshCallback, + initialStartDate, + initialEndDate, + deviceComponentPreferences + } = props; + const [isComponentSettingsOpen, setIsComponentSettingsOpen] = useState(false); + const [isAddInteractionOpen, setIsAddInteractionOpen] = useState(false); + const [isExportDataOpen, setIsExportDataOpen] = useState(false); + const [anchorEl, setAnchorEl] = useState(null); + const [componentSettingsMode, setComponentSettingsMode] = useState(""); + const [isJSON, setIsJSON] = useState(false); + const [{ user, newStructure }] = useGlobalState(); + + const openMoreMenu = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const closeMoreMenu = () => { + setAnchorEl(null); + setIsComponentSettingsOpen(false); + }; + + const openComponentSettingsDialog = (mode: string) => { + setIsComponentSettingsOpen(true); + setComponentSettingsMode(or(mode, "")); + }; + + const closeComponentSettingsDialog = () => { + setIsComponentSettingsOpen(false); + }; + + const menu = () => { + const canEdit = permissions.includes(pond.Permission.PERMISSION_WRITE); + const canAddInteraction = + canEdit && (isSource(component.settings.type) || isController(component.settings.type)); + return ( + + {canAddInteraction && ( + setIsAddInteractionOpen(true)} button divider> + + + + + + )} + { + setIsJSON(false); + setIsExportDataOpen(true); + }}> + + + + + + {canEdit && } + {canEdit && ( + openComponentSettingsDialog("remove")}> + + + + + + )} + + ); + }; + + const dialogs = () => { + const canEdit = permissions.includes(pond.Permission.PERMISSION_WRITE); + type Mode = "add" | "remove" | "update" | undefined; + return ( + + + setIsAddInteractionOpen(false)} + refreshCallback={refreshCallback} + canEdit={canEdit} + /> + setIsExportDataOpen(false)} + initialStartDate={initialStartDate} + initialEndDate={initialEndDate} + isJSON={isJSON} + newMeasurements={newStructure} + user={user} + /> + + ); + }; + + return ( + + event.stopPropagation()}> + openComponentSettingsDialog("")}> + + + + + + + + + {menu()} + {dialogs()} + + ); +} diff --git a/src/component/ComponentCard.tsx b/src/component/ComponentCard.tsx new file mode 100644 index 0000000..d59ada8 --- /dev/null +++ b/src/component/ComponentCard.tsx @@ -0,0 +1,390 @@ +import { + Avatar, + Box, + Card, + CardActionArea, + CardContent, + CardHeader, + Grid2 as Grid, + Switch, + Theme, + Tooltip, + Typography +} from "@mui/material"; +import EventBlocker from "common/EventBlocker"; +import MeasurementSummary from "component/MeasurementSummary"; +import { useComponentAPI, useSnackbar, useThemeType } from "hooks"; +import InteractionsOverview from "interactions/InteractionsOverview"; +import { cloneDeep } from "lodash"; +import { Component, Device, Interaction } from "models"; +import { getFriendlyAddressTypeName, getHumanReadableAddress } from "pbHelpers/AddressType"; +import { controllerModeLabel } from "pbHelpers/Component"; +import { + //extension, + GetComponentIcon, + getMeasurements, + isController +} from "pbHelpers/ComponentType"; +import { DeviceAvailabilityMap, OffsetAvailabilityMap } from "pbHelpers/DeviceAvailability"; +import { findInteractionsAsSource, HasInteraction } from "pbHelpers/Interaction"; +import { canWrite } from "pbHelpers/Permission"; +import { pond, quack } from "protobuf-ts/pond"; +import React, { useEffect, useState } from "react"; +// import { useHistory } from "react-router"; +import { hasDeviceFeature } from "services/feature/service"; +import ComponentActions from "./ComponentActions"; +import { or } from "utils"; +import { extractNodes } from "pbHelpers/ComponentTypes"; +import UnitMeasurementSummary from "./UnitMeasurementSummary"; +import { UnitMeasurement } from "models/UnitMeasurement"; +import { useGlobalState } from "providers"; +import { makeStyles } from "@mui/styles"; +import { useLocation, useNavigate } from "react-router-dom"; + +const useStyles = makeStyles((theme: Theme) => { + return ({ + card: { + position: "relative", + display: "flex", + height: "100%", + flexDirection: "column", + overflow: "visible" + }, + cardHeader: { + padding: theme.spacing(1), + paddingLeft: theme.spacing(2) + }, + cardContent: { + display: "flex", + flexDirection: "column", + justifyContent: "space-between", + alignItems: "center", + padding: theme.spacing(1), + paddingTop: 0 + }, + moreDetails: { + marginLeft: "auto" + }, + avatarIcon: { + width: theme.spacing(3), + height: theme.spacing(3) + }, + sensorDot: { + position: "absolute", + marginLeft: theme.spacing(1), + marginTop: theme.spacing(1) + } + }) +}); + +interface Sensor { + label: string; + color: string; + value: string; +} + +interface Props { + device: Device; + component: Component; + components: Component[]; + availablePositions: DeviceAvailabilityMap; + availableOffsets: OffsetAvailabilityMap; + permissions: pond.Permission[]; + interactions: Interaction[]; + refreshCallback: (updatedComponent?: Component) => void; + showMobile?: boolean; + showSensors?: boolean; + deviceComponentPreferences?: pond.DeviceComponentPreferences; +} + +export default function ComponentCard(props: Props) { + const componentAPI = useComponentAPI(); + const classes = useStyles(); + const themeType = useThemeType(); + const { error, success } = useSnackbar(); + const { + device, + component, + components, + availablePositions, + availableOffsets, + permissions, + refreshCallback, + interactions, + showMobile, + showSensors, + deviceComponentPreferences + } = props; + // const history = useHistory(); + const navigate = useNavigate() + const location = useLocation() + const [sensors, setSensors] = useState([]); + const [{ user, showErrors }] = useGlobalState(); + + const updateControllerState = (checked: boolean) => { + let updatedComponent = cloneDeep(component); + let newMode = + checked === false ? 2 : HasInteraction(component.location(), interactions) ? 0 : 1; + updatedComponent.settings.defaultOutputState = newMode; + let describe = controllerModeLabel(newMode); + + componentAPI + .update(device.id(), updatedComponent.settings) + .then(() => { + success(component.name() + "'s mode was set to " + describe); + refreshCallback(updatedComponent); + }) + .catch(() => { + error("Failed to set " + component.name() + "'s mode"); + }); + }; + + const pathToComponent = () => { + let url = location.pathname + "/components/" + component.key(); + url = url.replace("//", "/"); + return url; + }; + + const openComponentPage = (event: React.MouseEvent) => { + const { name, className } = event.target as HTMLButtonElement; + const blacklist = ["slider", "controllerSwitch"]; + if ( + blacklist.includes(name) || + blacklist.find( + v => + className && + className + .toString() + .toLowerCase() + .includes(v) + ) !== undefined + ) { + event.preventDefault(); + } else { + navigate(pathToComponent()); + } + }; + + const getAddressDescription = () => { + if ( + component.settings.addressType === quack.AddressType.ADDRESS_TYPE_CONFIGURABLE_PIN_ARRAY || + (component.settings.addressType >= quack.AddressType.ADDRESS_TYPE_PIN_OFFSET1 && + component.settings.addressType <= quack.AddressType.ADDRESS_TYPE_PIN_OFFSET100) + ) { + let addressDescription = getHumanReadableAddress( + component.settings.addressType, + component.settings.type, + component.settings.address, + device.settings.product + ); + if (addressDescription === "") { + addressDescription = "Internal"; + } + let port = "Port: " + addressDescription; + let cableID = ""; + if ( + component.settings.addressType >= quack.AddressType.ADDRESS_TYPE_PIN_OFFSET1 && + component.settings.addressType <= quack.AddressType.ADDRESS_TYPE_PIN_OFFSET100 + ) { + cableID = "Cable: " + (component.settings.addressType - 8); + } + return port + " " + cableID; + } else { + return getFriendlyAddressTypeName(component.settings.addressType); + } + }; + + const header = () => { + const canEdit = canWrite(permissions); + const componentIcon = GetComponentIcon( + component.settings.type, + component.settings.subtype, + themeType + ); + const name = component.name(); + + let measurements: UnitMeasurement[] = []; + if (component.status.lastGoodMeasurement) + measurements = component.status.lastGoodMeasurement.map(um => UnitMeasurement.any(um, user)); + + if (showErrors) + measurements = component.lastMeasurement.map(um => UnitMeasurement.any(um, user)); + + return ( + + ) : ( + + ) + } + title={ + + + Subtype: {component.subTypeName()} +
+ ID: {component.key()} +
+ Location: {component.locationString()} + + } + placement="top"> + + {name} - {getAddressDescription()} + +
+ {isController(component.settings.type) && + hasDeviceFeature(device.settings.upgradeChannel, "better-controls") && ( + + { + event.stopPropagation(); + event.preventDefault(); + }} + onMouseDown={event => { + event.stopPropagation(); + event.preventDefault(); + }} + onClick={event => { + event.stopPropagation(); + event.preventDefault(); + }} + disabled={!canEdit} + onChange={(_, checked) => updateControllerState(checked)} + name="controllerSwitch" + inputProps={{ "aria-label": "controller switch" }} + /> + + )} +
+ } + className={classes.cardHeader} + titleTypographyProps={{ variant: "subtitle1" }} + subheader={ + // !newStructure ? ( + // + // ) : ( + + // ) + } + action={ + + + + } + /> + ); + }; + + const content = () => { + if (findInteractionsAsSource(component.location(), interactions).length <= 0) return null; + return ( + + + refreshCallback()} + /> + + + ); + }; + + useEffect(() => { + if (!component) return; + if (!component.status) return; + if (!component.status.lastMeasurement) return; + let measurements = getMeasurements(component.type(), or(component.settings.subtype, 0)); + let nodes = extractNodes(component.status!.lastMeasurement!.measurement!); + let sensors: Sensor[] = []; + nodes.forEach(node => { + Object.values(node).forEach((value, index) => { + if (measurements[index]) { + let sensor: Sensor = { + label: measurements[index].label, + value: value, + color: measurements[index].colour + }; + sensors.push(sensor); + } + }); + }); + setSensors(sensors); + }, [component, setSensors]); + + const sensorCard = () => { + if (sensors.length <= 0 || !showSensors) return null; + return ( + + + {sensors.map((sensor, index) => { + return ( + + {sensor.label}: {sensor.value} + + ); + })} + + + ); + }; + + return ( + + + openComponentPage(event)} + component="div" + style={{ height: "100%" }}> + {header()} + {content()} + {sensorCard()} + + + + ); +} diff --git a/src/component/ComponentChart.tsx b/src/component/ComponentChart.tsx new file mode 100644 index 0000000..ccb3f1f --- /dev/null +++ b/src/component/ComponentChart.tsx @@ -0,0 +1,824 @@ +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Card, + CardContent, + Checkbox, + CircularProgress, + Divider, + FormControl, + FormControlLabel, + FormGroup, + FormLabel, + Grid2 as Grid, + Theme, + Typography +} from "@mui/material"; +import { lightGreen } from "@mui/material/colors"; +import { ExpandMore } from "@mui/icons-material"; +import MeasurementsChart from "charts/MeasurementsChart"; +//import { GraphOrientation } from "common/Graph"; +import RangeInput, { Range } from "common/RangeInput"; +import { GetDefaultDateRange } from "common/time/DateRange"; +import DateSelect from "common/time/DateSelect"; +import MeasurementSummary from "component/MeasurementSummary"; +import { grainName } from "grain"; +import { useMobile, usePrevious } from "hooks"; +import { cloneDeep } from "lodash"; +import { Component, Interaction } from "models"; +import { UnitMeasurement } from "models/UnitMeasurement"; +import moment from "moment"; +import { deviceComponentID } from "pbHelpers/Component"; +import { + extension, + //getComponentVisual, + GetNumNodes, + GetNumNodesFromUnitMeasurement, + GraphFilters +} from "pbHelpers/ComponentType"; +import { binSplitAt, isBinSplit } from "pbHelpers/ComponentTypes"; +import { findInteractionsAsSource } from "pbHelpers/Interaction"; +import { describeMeasurement } from "pbHelpers/MeasurementDescriber"; +import { pond } from "protobuf-ts/pond"; +import { quack } from "protobuf-ts/quack"; +import { useGlobalState } from "providers"; +import React, { useEffect, useState } from "react"; +import { or } from "utils/types"; +import UnitMeasurementSummary from "./UnitMeasurementSummary"; +import { makeStyles } from "@mui/styles"; + +const useStyles = makeStyles((theme: Theme) => { + return ({ + borderedContainer: { + padding: theme.spacing(1), + marginBottom: theme.spacing(2), + border: "1px solid rgba(255, 255, 255, 0.12)", + borderRadius: "4px" + }, + container: { + padding: theme.spacing(1), + marginBottom: theme.spacing(2) + }, + grainNode: { + color: lightGreen[700] + }, + airNode: { + color: theme.palette.text.primary + }, + divider: { + margin: theme.spacing(1) + }, + fullWidth: { + width: "100%" + } + }) +}); + +interface Props { + deviceKey: string; + componentKey: string; + component: Component; + interactions: Interaction[]; + sampling: boolean; + samples?: pond.Measurement[]; //deprecated: old measurement structure + unitMeasurements?: UnitMeasurement[]; + sampleLimit?: number; + updateDate?: (startDate: any, endDate: any, live?: boolean) => void; + allowLive?: boolean; + recentMeasurement?: pond.Measurement; + recentUnitMeasurement?: UnitMeasurement[]; + deviceComponentPreferences?: pond.DeviceComponentPreferences; +} + +function setComponentGraphRanges(device: number | string, component: string, ranges: Range[]) { + const key: string = "graphRanges-" + deviceComponentID(device, component); + localStorage.setItem(key, JSON.stringify(or(ranges, []))); +} + +function getComponentGraphRanges(device: number | string, component: string): Range[] { + const key: string = "graphRanges-" + deviceComponentID(device, component); + return JSON.parse(or(localStorage.getItem(key), "[]")); +} + +export default function ComponentChart(props: Props) { + const { component, deviceComponentPreferences } = props; + const classes = useStyles(); + const isMobile = useMobile(); + const { deviceKey, componentKey } = props; + const prevDevice = usePrevious(props.deviceKey); + const prevComponentKey = usePrevious(props.componentKey); + const prevComponent = usePrevious(props.component); + const defaultDateRange = GetDefaultDateRange(); + const [startDate, setStartDate] = useState(defaultDateRange.start); + const [endDate, setEndDate] = useState(defaultDateRange.end); + const [live, setLive] = useState(defaultDateRange.live); + const [graphFilters, setGraphFilters] = useState({ + selectedNodes: ["all"], + selectedInteractions: [], + selectedOverlays: [], + grainType: pond.Grain.GRAIN_NONE + }); + // const [{ newStructure }] = useGlobalState(); + const [expandOverlays, setExpandOverlays] = useState(false); + const [expandInteractions, setExpandInteractions] = useState(false); + const [expandData, setExpandData] = useState(false); + const [showOriginal, setShowOriginal] = useState(true); + const [showAveraged, setShowAveraged] = useState(false); + + useEffect(() => { + if (prevComponent !== props.component) { + let updatedFilters = cloneDeep(graphFilters); + let filledTo = props.component.settings.grainFilledTo; + updatedFilters.grainFilledTo = filledTo > 0 ? filledTo : undefined; + updatedFilters.grainType = props.component.settings.grainType; + setGraphFilters(updatedFilters); + } + }, [prevComponent, graphFilters, props.component]); + + useEffect(() => { + if (prevDevice !== deviceKey || prevComponentKey !== componentKey) { + const ranges = getComponentGraphRanges(deviceKey, componentKey); + let updatedFilters = cloneDeep(graphFilters); + updatedFilters.ranges = ranges; + setGraphFilters(updatedFilters); + } + }, [deviceKey, prevDevice, prevComponentKey, componentKey, graphFilters]); + + //use the excluded nodes to set the default for the graph filters + useEffect(() => { + let excludedNodes = deviceComponentPreferences?.excludedNodes; + if (excludedNodes === undefined || excludedNodes.length === 0) return; + let updatedFilters = cloneDeep(graphFilters); + if (excludedNodes && component.lastMeasurement[0]) { + let includedNodes: string[] = []; + let numNodes = GetNumNodesFromUnitMeasurement( + UnitMeasurement.any(component.lastMeasurement[0]) + ); + + for (let i = 0; i < numNodes; i++) { + if (!excludedNodes.includes(i)) { + includedNodes.push(i.toString()); + } + } + updatedFilters.selectedNodes = includedNodes; + } + setGraphFilters(updatedFilters); + }, [deviceComponentPreferences, component]); //eslint-disable-line react-hooks/exhaustive-deps + + const updateDateRange = (newStartDate: any, newEndDate: any, live: boolean) => { + setStartDate(newStartDate); + setEndDate(newEndDate); + setLive(live); + if (props.updateDate) { + props.updateDate(newStartDate, newEndDate, live); + } + }; + + const changeRange = (index: number, range: Range) => { + let updatedFilters = cloneDeep(graphFilters); + let ranges = updatedFilters.ranges ? updatedFilters.ranges : []; + ranges[index] = range; + updatedFilters.ranges = ranges; + setComponentGraphRanges(deviceKey, componentKey, ranges); + setGraphFilters(updatedFilters); + }; + + const handleGrainCableControlsChange = (name: string) => (event: any) => { + let updatedFilters = cloneDeep(graphFilters); + let selectedNodes = updatedFilters.selectedNodes ? updatedFilters.selectedNodes : ["all"]; + if (event.target.checked) { + if (name === "all" || name === "grain" || name === "air") { + selectedNodes = [name]; + } else { + selectedNodes = selectedNodes.filter(item => { + let str = item.toString(); + return str !== "all" && str !== "air" && str !== "grain"; + }); + selectedNodes.push(name); + } + } else { + if (selectedNodes.includes(name)) { + selectedNodes = selectedNodes.filter(item => item.toString() !== name.toString()); + if (selectedNodes.length < 1 && name !== "all") { + selectedNodes = ["all"]; + } + } + } + updatedFilters.selectedNodes = selectedNodes; + setGraphFilters(updatedFilters); + }; + + const cableControls = () => { + const { component, samples, deviceComponentPreferences } = props; + const excludedNodes = deviceComponentPreferences?.excludedNodes ?? []; + const selectedNodes = graphFilters.selectedNodes ? graphFilters.selectedNodes : ["all"]; + const lastMeasurement = component.status.lastMeasurement; + let numNodes = 0; + if (samples) { + numNodes = GetNumNodes( + component.settings.type, + lastMeasurement && lastMeasurement.measurement + ); + } else { + if (component.lastMeasurement && component.lastMeasurement[0]) { + numNodes = GetNumNodesFromUnitMeasurement( + UnitMeasurement.any(component.lastMeasurement[0]) + ); + } + } + + if (numNodes <= 1) { + return null; + } + + let grainCableControls = []; + let split = isBinSplit(graphFilters); + let splitAt = binSplitAt(graphFilters); + + for (var i = numNodes; i > 0; i--) { + if (i === numNodes) { + grainCableControls.push( + + + } + label={"All"} + /> + {split && ( + + } + label={ + // newStructure ? ( + + {graphFilters.grainType ? grainName(graphFilters.grainType) : "Grain"} + + // ) : ( + // + // {graphFilters.grainType ? grainName(graphFilters.grainType) : "Grain"} + // + // ) + } + /> + )} + {split && ( + + } + label={Air} + /> + )} + + + ); + } + + let nodeLabel = "Node " + i.toString(); + if (i === 1) { + nodeLabel = "Bottom"; + } else if (i === numNodes) { + nodeLabel = "Top"; + } + + //TODO-DRAGER: this mey not be necessary to do anymore with them being split up + if (component.type() === quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE) { + let describer = describeMeasurement( + component.lastMeasurement[0].type, + component.type(), + component.subType() + ); + let details = describer.nodeDetails(); + if (details) { + nodeLabel = details.labels[i - 1]; + } + } + + grainCableControls.push( + + + } + label={{nodeLabel}} + /> + + ); + } + return numNodes > 0 ? ( + + + Node Control + + + + {grainCableControls} + + + + ) : null; + }; + + const componentTypeControls = () => { + const { component } = props; + const ext = extension(component.settings.type, component.settings.subtype); + if (ext.isArray) { + return cableControls(); + } + return null; + }; + + const updateInteractions = (i: Interaction) => { + let updatedFilters = cloneDeep(graphFilters); + let selectedInteractions = updatedFilters.selectedInteractions + ? updatedFilters.selectedInteractions + : []; + if (selectedInteractions.includes(i.key())) { + selectedInteractions.splice(selectedInteractions.indexOf(i.key()), 1); + } else { + selectedInteractions.push(i.key()); + } + updatedFilters.selectedInteractions = selectedInteractions; + setGraphFilters(updatedFilters); + }; + + const interactionControls = () => { + const { component, interactions } = props; + let componentID: quack.ComponentID = quack.ComponentID.fromObject({ + address: component.settings.address, + addressType: component.settings.addressType, + type: component.settings.type + }); + let sourceInteractions = findInteractionsAsSource(componentID, interactions); + const selectedInteractions = graphFilters.selectedInteractions + ? graphFilters.selectedInteractions + : []; + + //create checkboxes for each interaction and as they are checked/unchecked add/remove them from the interactions passed to getComponentVisual + return interactions.length > 0 ? ( + + { + setExpandInteractions(expanded); + }}> + }> + + Interactions + + + + + {sourceInteractions.map((interaction, i) => ( + + updateInteractions(interaction)} + value={interaction} + /> + } + label={Interaction {i + 1}} + /> + + ))} + + + + + ) : null; + + // return interactions.length > 0 ? ( + // + // Interactions + // + // + // {sourceInteractions.map((interaction, i) => ( + // + // updateInteractions(interaction)} + // value={interaction} + // /> + // } + // label={"Interaction " + (i + 1)} + // /> + // + // ))} + // + // + // + // ) : null; + }; + + const updateOverlays = (overlayIndex: number) => { + let updatedFilters = cloneDeep(graphFilters); + // let selectedInteractions = updatedFilters.selectedInteractions + // ? updatedFilters.selectedInteractions + // : []; + let selectedOverlays = updatedFilters.selectedOverlays ?? []; + if (selectedOverlays.includes(overlayIndex.toString())) { + selectedOverlays.splice(selectedOverlays.indexOf(overlayIndex.toString()), 1); + } else { + selectedOverlays.push(overlayIndex.toString()); + } + updatedFilters.selectedOverlays = selectedOverlays; + setGraphFilters(updatedFilters); + }; + + const overlayControls = () => { + const { component } = props; + // let componentID: quack.ComponentID = quack.ComponentID.fromObject({ + // address: component.settings.address, + // addressType: component.settings.addressType, + // type: component.settings.type + // }); + let overlays = component.settings.overlays; + if (overlays.length === 0) return; + const selectedOverlays = graphFilters.selectedOverlays ?? []; + // if (newStructure) { + return overlays.length > 0 ? ( + + { + setExpandOverlays(expanded); + }}> + }> + + Overlays + + + + + {overlays.map((overlay, i) => ( + + + updateOverlays(i)} + value={overlay.message} + /> + } + label={ + + + {overlay.message} + + + + + + } + /> + + + ))} + + + + + ) : null; + // } + // return overlays.length > 0 ? ( + // + // Overlays + // + // + // {overlays.map((overlay, i) => ( + // + // updateOverlays(i)} + // value={overlay.message} + // /> + // } + // label={overlay.message} + // /> + // + // ))} + // + // + // + // ) : null; + }; + + const dataDisplayControls = () => { + const { unitMeasurements } = props; + return ( + + { + setExpandData(expanded); + }}> + }> + + Data Display Options + + + + + + + { + setShowOriginal(checked); + }} + value={showOriginal} + /> + } + label={ + + + OriginalData + + + + {unitMeasurements?.map((um, i) => ( + + + + ))} + + + + } + /> + + + + + { + setShowAveraged(checked); + }} + value={showAveraged} + /> + } + label={ + + + Averaged Data + + + + + + } + /> + + + + + + + ); + }; + + const getGraph = () => { + const { component, interactions, unitMeasurements, sampleLimit } = props; + // if (samples && samples.length > 1) { + // //TODO: is being deprecated + // return getComponentVisual( + // component.settings.type, + // component.settings.subtype, + // samples, + // interactions, + // component.settings.overlays, + // GraphOrientation.GRAPH_ORIENTATION_LANDSCAPE, + // graphFilters + // ); + // } else + if (unitMeasurements) { + return ( + + + + ); + } else { + return ( + + + Not enough data to display + + + {live + ? "(Waiting to receive a few live measurements)" + : "(Queried between " + + moment(startDate).calendar() + + " and " + + moment(endDate).calendar() + + ")"} + + + ); + } + }; + + const content = () => { + const { + component, + sampling, + samples, + recentMeasurement, + recentUnitMeasurement, + deviceComponentPreferences + } = props; + const ext = extension(component.settings.type, component.settings.subtype); + const ranges = graphFilters.ranges ? graphFilters.ranges : []; + let convertedLastMeasurement; + + if (recentUnitMeasurement && recentUnitMeasurement.length > 0) { + let measurements = recentUnitMeasurement; + convertedLastMeasurement = UnitMeasurement.convertLastMeasurement(measurements); + } + return ( + + {isMobile && ( + + {samples ? ( + + ) : ( + + Latest Measurements: + + + )} + + )} + + {sampling ? : {getGraph()}} + + + {!isMobile && ( + + {/* {newStructure && ( */} + + Status + + + {/* )} */} + + {samples ? ( + + ) : ( + + Latest Measurements: + + + )} + + + )} + {!isMobile && ( + + Graph Controls + + + )} + + {componentTypeControls()} + + + {interactionControls()} + + + {overlayControls()} + + {component.settings.smoothingAverages !== 0 && ( + + {dataDisplayControls()} + + )} + + {!sampling && + ext.measurements.map((m, i) => { + const range = ranges[i] ? ranges[i] : ({} as Range); + if (m.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_BOOLEAN) { + return null; + } + if (samples) { + return ( + + changeRange(i, updatedRange)} + label={m.label + " Range"} + /> + + ); + } + return null; + })} + + + ); + }; + + return ( + + {content()} + + ); +} diff --git a/src/component/ComponentDiagnostics.tsx b/src/component/ComponentDiagnostics.tsx new file mode 100644 index 0000000..902b768 --- /dev/null +++ b/src/component/ComponentDiagnostics.tsx @@ -0,0 +1,266 @@ +import { + Button, + Card, + CardContent, + CardHeader, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Theme, + Typography +} from "@mui/material"; +import { Component, Device } from "models"; +import moment from "moment"; +import { getHumanReadableAddress } from "pbHelpers/AddressType"; +import { pond } from "protobuf-ts/pond"; +import { quack } from "protobuf-ts/quack"; +import { useComponentAPI } from "providers"; +import React, { useEffect, useState } from "react"; +// import { getThemeType } from "theme"; +import AddCompsFromDiag from "./AddCompsFromDiag"; +import { getThemeType } from "theme/themeType"; +import { makeStyles } from "@mui/styles"; + +interface Props { + component: Component; + device: Device; + refreshCallback: () => void; + //not worrying about permissions for this component as this would only be rendered if the user has write permissions to the device + //permissions: pond.Permission[] +} + +export interface CableInfo { + id: number; + subtype: quack.GrainCableSubtype | undefined; + name: string; + nodeCount: number; +} + +const useStyles = makeStyles((theme: Theme) => { + return ({ + cardHeader: { + padding: theme.spacing(1), + paddingLeft: theme.spacing(2) + }, + cell: { + padding: 5 + }, + dark: { + backgroundColor: getThemeType() === "light" ? "rgb(245, 245, 245)" : "rgb(50, 50, 50)", + padding: 0 + }, + light: { + backgroundColor: getThemeType() === "light" ? "rgb(235, 235, 235)" : "rgb(60, 60, 60)", + padding: 0 + } + }) +}); + +export default function ComponentDiagnostics(props: Props) { + const { component, device, refreshCallback } = props; + const [portNumber, setPortNumber] = useState(""); + const compAPI = useComponentAPI(); + const classes = useStyles(); + const [cableInfoList, setCableInfoList] = useState([]); + const [measurements, setMeasurements] = useState([]); + //const { error, success } = useSnackbar(); + const [loadingMeasurements, setLoadingMeasurements] = useState(false); + const [openAdd, setOpenAdd] = useState(false); + + useEffect(() => { + setPortNumber( + getHumanReadableAddress( + component.settings.addressType, + component.settings.type, + component.settings.address, + device.settings.product + ) + ); + //get the last 10 measurements that occured over a 24 hour period + if (!loadingMeasurements) { + setLoadingMeasurements(true); + compAPI + .listUnitMeasurements( + device.id(), + component.key(), + moment() + .subtract(1, "hours") + .toISOString(), + moment().toISOString(), + 2, + 0, + "desc" + ) + .then(resp => { + setMeasurements(resp.data.measurements); + }) + .finally(() => { + setLoadingMeasurements(false); + }); + } + }, [component, device, compAPI]); //eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + let cableIDs: number[] = []; + let cableInfo: CableInfo[] = []; + + if (measurements) { + measurements.forEach(UnitMeasurement => { + let unitM = pond.UnitMeasurementsForComponent.fromObject(UnitMeasurement); + if (unitM.type === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) { + if (unitM.values[0] && unitM.values[0].values) { + cableIDs = unitM.values[0].values; + } + } else if (unitM.type === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT) { + unitM.values.forEach(ValArray => { + ValArray.values.forEach((hum, i) => { + if (cableIDs[i]) { + let newInfo = cableFromHumidity(cableIDs[i], hum); + if (cableInfo[i] === undefined) { + //for the first round just put them in + cableInfo.push(newInfo); + } else { + //if something is there do a check to see if they match + if (cableInfo[i].subtype !== newInfo.subtype) { + //if they dont match determine if the newInfo has priority + if ( + newInfo.subtype === quack.GrainCableSubtype.GRAIN_CABLE_SUBTYPE_NONE || + newInfo.subtype === quack.GrainCableSubtype.GRAIN_CABLE_SUBTYPE_OPI_SHT35 + ) { + //if it does then set the V# to be the cable info otherwise leave it alone + cableInfo[i] = newInfo; + } + } + } + } + }); + }); + } + }); + } + setCableInfoList(cableInfo); + }, [measurements]); + + const cableFromHumidity = (cableID: number, humidity: number) => { + let nodeCoeff = 0; + let cableInfo: CableInfo = { id: cableID, subtype: undefined, name: "Unknown", nodeCount: 0 }; + if (humidity >= 1 && humidity <= 16) { + cableInfo.subtype = quack.GrainCableSubtype.GRAIN_CABLE_SUBTYPE_NONE; + cableInfo.name = "Moisture Cable"; + nodeCoeff = 0; + } else if (humidity >= 17 && humidity <= 32) { + cableInfo.subtype = quack.GrainCableSubtype.GRAIN_CABLE_SUBTYPE_FROG; + cableInfo.name = "Adaptive Sensor"; + nodeCoeff = 1; + } else if (humidity >= 33 && humidity <= 48) { + cableInfo.subtype = quack.GrainCableSubtype.GRAIN_CABLE_SUBTYPE_OPI_TEMP; + cableInfo.name = "Temp Only Cable"; + nodeCoeff = 2; + } else if (humidity >= 49) { + cableInfo.subtype = quack.GrainCableSubtype.GRAIN_CABLE_SUBTYPE_OPI_SHT35; + cableInfo.name = "Moisture Cable V2"; + nodeCoeff = 3; + } + cableInfo.nodeCount = humidity - 16 * nodeCoeff; + return cableInfo; + }; + + // deprecated: this functionality has moved to the AddCompsFromDiag component + // const addCablesToDevice = () => { + // let componentsToAdd: pond.MultiComponentSettings = pond.MultiComponentSettings.create(); + // cableInfoList.forEach(cable => { + // let settings: pond.ComponentSettings = pond.ComponentSettings.create(); + // settings.type = quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE; + // settings.subtype = cable.subtype ?? quack.GrainCableSubtype.GRAIN_CABLE_SUBTYPE_NONE; + // settings.addressType = cable.id + 8; + // settings.address = component.settings.address; + // settings.measurementPeriodMs = 3600000; //set the measurement period to 1 hour + // settings.reportPeriodMs = 3600000; + // settings.name = cable.name; + // componentsToAdd.components.push(settings); + // }); + // compAPI + // .addMultiComponents(device.id(), componentsToAdd) + // .then(resp => { + // success("Components added to Device"); + // }) + // .catch(err => { + // error("One or more component failed to add"); + // }) + // .finally(() => { + // refreshCallback(); + // }); + // }; + + return ( + + + Port {portNumber} Auto Detection} + className={classes.cardHeader} + /> + + {measurements && measurements.length > 0 && ( + + + + + + Cable ID + + + Cable Type + + + Nodes + + + + + {cableInfoList.map((cable, i) => ( + + + {cable.id} + + + {cable.name} + + + {cable.nodeCount} + + + ))} + +
+ {cableInfoList.length > 0 && ( + + )} +
+ )} +
+
+ { + setOpenAdd(false); + }} + refreshCallback={refreshCallback} + /> +
+ ); +} diff --git a/src/component/ComponentForm.tsx b/src/component/ComponentForm.tsx new file mode 100644 index 0000000..6dd90fd --- /dev/null +++ b/src/component/ComponentForm.tsx @@ -0,0 +1,1322 @@ +import { + Accordion, + AccordionDetails, + AccordionSummary, + Alert, + AlertTitle, + Button, + Collapse, + FormControl, + FormControlLabel, + Grid2 as Grid, + IconButton, + InputAdornment, + MenuItem, + Switch, + TextField, + Theme, + Tooltip, + Typography +} from "@mui/material"; +import { + ExpandMore, + Close as CloseIcon +} from "@mui/icons-material"; +// import { Alert, AlertTitle } from "@material-ui/lab"; +import PeriodSelect from "common/time/PeriodSelect"; +import SearchSelect, { Option } from "common/SearchSelect"; +// import { GrainOptions } from "grain/GrainDescriber"; +import { cloneDeep } from "lodash"; +import { Component, Device } from "models"; +import { + extension, + getMeasurements, + hasDimensions, + hasFan, + isController, + isMultiSensor, + primaryMeasurement +} from "pbHelpers/ComponentType"; +import { describeMeasurement, MeasurementDescriber } from "pbHelpers/MeasurementDescriber"; +import { pond } from "protobuf-ts/pond"; +import { quack } from "protobuf-ts/quack"; +import React, { useEffect, useState } from "react"; +import { bestUnit, milliToX } from "common/time/duration"; +// import CompModes from "component/ComponentMode.json"; +// import green from "@material-ui/core/colors/green"; +// import red from "@material-ui/core/colors/red"; +// import FanPicker from "fans/fanPicker"; +import { useGlobalState } from "providers"; +import { green, red } from "@mui/material/colors"; +import { makeStyles } from "@mui/styles"; +import { getDistanceUnit } from "utils"; +import { GrainOptions } from "grain"; +// import { getDistanceUnit } from "utils"; + +const useStyles = makeStyles((theme: Theme) => { + return ({ + stepper: { + paddingRight: 0, + paddingLeft: 0 + }, + switchControl: { + marginLeft: theme.spacing(0.25), + [theme.breakpoints.up("sm")]: { + marginLeft: theme.spacing(1) + } + }, + selectInput: { + minHeight: "48px" + }, + fullWidth: { + width: "100%" + }, + heading: { + fontSize: theme.typography.pxToRem(13), + flexBasis: "25%", + flexShrink: 0 + }, + secondaryHeading: { + fontSize: theme.typography.pxToRem(13), + color: theme.palette.text.secondary + }, + greenButton: { + color: green["600"] + }, + redButton: { + color: red["600"] + }, + noPadding: { + padding: 0 + }, + dataUsageCard: { width: "100%" }, + dataUsageHeader: { paddingBottom: 0 }, + dataUsageIcon: { color: "var(--status-warning)" }, + dataUsageContent: { paddingTop: 0 } + }) +}); + +interface Props { + device: Device; + component: Component; + componentChanged: (component: Component, formValid: boolean) => void; + canEdit: boolean; +} + +interface ComponentData { + component: Component; + measure: boolean; + report: boolean; + partiallyFilled: boolean; + coefficient: string; + offset: string; + //these are for the new calibrations + coefficients: string[]; + offsets: string[]; + length: string; + height: string; + width: string; + sensorDistance: string; +} + +export default function ComponentForm(props: Props) { + const classes = useStyles(); + const grainOptions = GrainOptions(); + const [{ user }] = useGlobalState(); + const { device, component, componentChanged, canEdit } = props; + const [reportExpanded, setReportExpanded] = useState(false); + const [dataUsageWarningDismissed, setDataUsageWarningDismissed] = useState(true); + const [form, setForm] = useState({ + component: cloneDeep(component), + measure: false, + report: false, + partiallyFilled: false, + coefficient: "1", + offset: "0", + coefficients: [""], + offsets: [""], + length: "0", + height: "0", + width: "0", + sensorDistance: "0" + }); + const [compMode, setCompMode] = useState(); + //const [numCalibrations, setNumCalibrations] = useState(0) + + useEffect(() => { + let formComponent = cloneDeep(component); + let describer = describeMeasurement( + primaryMeasurement(formComponent.settings.type), + formComponent.settings.type, + formComponent.settings.subtype + ); + let offset = describer.toDisplay(formComponent.settings.calibrationOffset); + let formCoefficients: string[] = []; + let formOffsets: string[] = []; + //setNumCalibrations(formComponent.settings.calibrations.length) + formComponent.settings.calibrations.forEach(calibration => { + formCoefficients.push(calibration.calibrationCoefficient.toString()); + formOffsets.push(describer.toDisplay(calibration.calibrationOffset).toString()); + }); + if ( + formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE && + formComponent.settings.grainType === pond.Grain.GRAIN_INVALID + ) { + formComponent.settings.grainType = pond.Grain.GRAIN_NONE; + } + setReportExpanded( + formComponent.settings.measurementPeriodMs !== formComponent.settings.reportPeriodMs + ); + setDataUsageWarningDismissed(localStorage.getItem("dataUsageWarningDismissed") !== null); + // CompModes.forEach((mode: any) => { + // if (formComponent.type() === mode.type && formComponent.subType() === mode.subtype) { + // setCompMode(mode); + // } + // }); + + //get the dimension values for the form and convert them to the form display values (m or ft) + let length = formComponent.settings.containerDimensions?.lengthCm ?? 0; + let width = formComponent.settings.containerDimensions?.widthCm ?? 0; + let height = formComponent.settings.containerDimensions?.heightCm ?? 0; + + if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) { + length = length / 100; + width = width / 100; + height = height / 100; + } else if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) { + length = length / 30.48; + width = width / 30.48; + height = height / 30.48; + } + + let sensorDistance = formComponent.settings.sensorDistanceCm ?? 0; + + if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) { + sensorDistance = sensorDistance / 100; + } else if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) { + sensorDistance = sensorDistance / 30.48; + } + + setForm({ + component: formComponent, + measure: formComponent.settings.measurementPeriodMs > 0, + report: formComponent.settings.reportPeriodMs > 0, + partiallyFilled: formComponent.settings.grainFilledTo > 0, + coefficient: formComponent.settings.calibrationCoefficient.toString(), + offset: offset.toString(), + coefficients: formCoefficients, + offsets: formOffsets, + length: length.toFixed(2), + width: width.toFixed(2), + height: height.toFixed(2), + sensorDistance: sensorDistance.toFixed(2) + }); + }, [component]); + + const tidyComponent = (component: Component): Component => { + const { measure, report } = form; + if (!report) { + component.settings.reportPeriodMs = 0; + } + if (!measure) { + component.settings.measurementPeriodMs = 0; + } + return component; + }; + + const isFormValid = () => { + let { component } = form; + return ( + isNameValid(component.settings.name) && + isMeasurementPeriodValid() && + isReportPeriodValid() && + isFilledToValid() && + isCoefficientValid() && + isOffsetValid() && + isMinCycleTimeValid() && + isLengthDimensionValid() && + isHeightDimensionValid() && + isWidthDimensionValid() && + isSensorDistanceValid() + ); + }; + + useEffect(() => { + let comp = tidyComponent(form.component); + componentChanged(comp, isFormValid()); + }, [form.component, componentChanged]); // eslint-disable-line react-hooks/exhaustive-deps + + const isNameValid = (name: string): boolean => { + return name.length > 0; + }; + + const isMeasurementPeriodValid = () => { + const { measure, component } = form; + const ext = extension(component.settings.type, component.settings.subtype); + let valid = true; + if ( + measure && + (component.settings.measurementPeriodMs < 1000 || + component.settings.measurementPeriodMs < ext.minMeasurementPeriodMs) + ) { + valid = false; + } + return valid; + }; + + const isFilledToValid = () => { + const { partiallyFilled, component } = form; + return (partiallyFilled && component.settings.grainFilledTo > 0) || !partiallyFilled; + }; + + const minMeasurementPeriodDescription = () => { + const { component } = form; + const ext = extension(component.settings.type, component.settings.subtype); + const min = Math.max(1000, ext.minMeasurementPeriodMs); + const unit = bestUnit(min); + const value = milliToX(min, unit).toString(); + return value + " " + unit; + }; + + const isReportPeriodValid = () => { + const { report, component } = form; + const ext = extension(component.settings.type, component.settings.subtype); + let valid = true; + if ( + report && + (component.settings.reportPeriodMs < 10000 || + component.settings.reportPeriodMs < ext.minMeasurementPeriodMs) + ) { + valid = false; + } + return valid; + }; + + const minReportPeriodDescription = () => { + const { component } = form; + const ext = extension(component.settings.type, component.settings.subtype); + const min = Math.max(10000, ext.minMeasurementPeriodMs); + const unit = bestUnit(min); + const value = milliToX(min, unit).toString(); + return value + " " + unit; + }; + + const isMinCycleTimeValid = () => { + const { component } = form; + const ext = extension(component.settings.type, component.settings.subtype); + if (!ext.isController) return true; + + let min = Math.max(0, ext.minCycleTimeMs ? ext.minCycleTimeMs : 0); + return component.settings.minCycleTimeMs >= min; + }; + + const minCycleTimeDescription = () => { + const { component } = form; + const ext = extension(component.settings.type, component.settings.subtype); + if (!ext.isController) return ""; + + const min = Math.max(0, ext.minCycleTimeMs ? ext.minCycleTimeMs : 0); + const unit = bestUnit(min); + const value = milliToX(min, unit).toString(); + return "Must be at least " + value + " " + unit; + }; + + const handleNameChanged = (event: any) => { + let f = cloneDeep(form); + f.component.settings.name = event.target.value; + setForm(f); + }; + + const handleOutputModeChanged = (event: any) => { + let f = cloneDeep(form); + f.component.settings.defaultOutputState = event.target.value; + setForm(f); + }; + + const handleReportPeriodChanged = (ms: number) => { + let f = cloneDeep(form); + f.component.settings.reportPeriodMs = ms; + setForm(f); + }; + + const handleMeasurementPeriodChanged = (ms: number) => { + let f = cloneDeep(form); + f.component.settings.measurementPeriodMs = ms; + if (!reportExpanded) { + f.component.settings.reportPeriodMs = ms; + } + setForm(f); + }; + + const handleMinCycleTimeChanged = (ms: number) => { + let f = cloneDeep(form); + f.component.settings.minCycleTimeMs = Number(ms); + setForm(f); + }; + + const toggleMeasure = (event: any) => { + let f = cloneDeep(form); + f.measure = event.target.checked; + if (!reportExpanded) { + f.report = event.target.checked; + } + setForm(f); + }; + + const toggleReport = (event: any) => { + let f = cloneDeep(form); + + f.report = event.target.checked; + setForm(f); + }; + + const togglePartiallyFilled = (event: any) => { + let f = cloneDeep(form); + + f.partiallyFilled = event.target.checked; + if (event.target.checked && f.component.settings.grainFilledTo <= 0) { + f.component.settings.grainFilledTo = 1; + } + if (!event.target.checked) { + f.component.settings.grainFilledTo = 0; + } + setForm(f); + }; + + const updateGrainType = (option: Option | null) => { + let f = cloneDeep(form); + + if (!option) { + f.component.settings.grainType = pond.Grain.GRAIN_INVALID; + } else { + let grainType = pond.Grain[option.value as keyof typeof pond.Grain]; + f.component.settings.grainType = grainType; + if (grainType === pond.Grain.GRAIN_NONE) { + f.component.settings.defaultMutations.splice( + f.component.settings.defaultMutations.indexOf(pond.Mutator.MUTATOR_EMC, 1) + ); + } else if (!f.component.settings.defaultMutations.includes(pond.Mutator.MUTATOR_EMC)) { + f.component.settings.defaultMutations.push(pond.Mutator.MUTATOR_EMC); + } + } + setForm(f); + }; + + const updateGrainFilledTo = (event: any) => { + let f = cloneDeep(form); + f.component.settings.grainFilledTo = Number(event.target.value); + setForm(f); + }; + + const toggleCalibrate = () => { + let f = cloneDeep(form); + f.component.settings.calibrate = !f.component.settings.calibrate; + let isZero = f.coefficient === "0" || f.coefficient === ""; + if (f.component.settings.calibrate && isZero) { + f.coefficient = "1"; + f.component.settings.calibrationCoefficient = 1; + } + let offsetZero = f.offset === "0" || f.offset === ""; + if (f.component.settings.calibrate && offsetZero) { + f.offset = "1"; + f.component.settings.calibrationOffset = 1; + } + setForm(f); + }; + + const isCoefficientValid = () => { + let calibrate = form.component.settings.calibrate; + let coefficient = Number(form.coefficient); + let valid = !isNaN(coefficient) && (!calibrate || (calibrate && coefficient > 0)); + return valid; + }; + + const changeCoefficient = (event: any) => { + let f = cloneDeep(form); + let value = event.target.value; + f.coefficient = value; + if (!isNaN(Number(value))) { + f.component.settings.calibrationCoefficient = +Number(value); + } + setForm(f); + }; + + const updateCoefficients = (event: any, index: number) => { + let f = cloneDeep(form); + let value = event.target.value; + f.coefficients[index] = value; + if (!isNaN(Number(value))) { + f.component.settings.calibrations[index].calibrationCoefficient = +Number(value); + } + setForm(f); + }; + + const isOffsetValid = (min?: number, max?: number) => { + let minimum = min ?? 0; + let maximum = max ?? 100; + if (compMode && form.component.settings.calibrate) { + return Number(form.offset) <= maximum && Number(form.offset) > minimum; + } + return !isNaN(Number(form.offset)); + }; + + const changeOffset = (event: any) => { + let f = cloneDeep(form); + f.offset = event.target.value; + if (!isNaN(Number(event.target.value))) { + f.component.settings.calibrationOffset = describeMeasurement( + primaryMeasurement(f.component.settings.type), + f.component.settings.type, + f.component.settings.subtype + ).toStored(+event.target.value); + } + setForm(f); + }; + + const updateOffsets = (event: any, index: number) => { + let f = cloneDeep(form); + f.offsets[index] = event.target.value; + if (!isNaN(+event.target.value)) { + f.component.settings.calibrations[index].calibrationOffset = describeMeasurement( + primaryMeasurement(f.component.settings.type), + f.component.settings.type, + f.component.settings.subtype + ).toStored(+event.target.value); + } + setForm(f); + }; + + const updateCalibrationTypes = (event: any, index: number) => { + let f = cloneDeep(form); + f.component.settings.calibrations[index].type = event.target.value as quack.MeasurementType; + setForm(f); + }; + + const changeAverages = (event: any) => { + let f = cloneDeep(form); + if (!isNaN(Number(event.target.value))) { + f.component.settings.smoothingAverages = event.target.value; + } + setForm(f); + }; + + const changeLengthDimension = (event: any) => { + let f = cloneDeep(form); + f.length = event.target.value; + if (!isNaN(Number(event.target.value))) { + let dimensions = f.component.settings.containerDimensions; + let value = Number(event.target.value); + if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) { + value = value * 100; + } else if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) { + value = value * 30.48; + } + if (dimensions !== undefined && dimensions !== null) { + dimensions.lengthCm = value; + } else { + dimensions = pond.Dimensions.create({ + heightCm: 0, + lengthCm: value, + widthCm: 0 + }); + } + f.component.settings.containerDimensions = dimensions; + } + setForm(f); + }; + + const isLengthDimensionValid = () => { + return !isNaN(Number(form.length)); + }; + + const changeHeightDimension = (event: any) => { + let f = cloneDeep(form); + f.height = event.target.value; + if (!isNaN(Number(event.target.value))) { + let dimensions = f.component.settings.containerDimensions; + let value = Number(event.target.value); + if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) { + value = value * 100; + } else if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) { + value = value * 30.48; + } + if (dimensions !== undefined && dimensions !== null) { + dimensions.heightCm = value; + } else { + dimensions = pond.Dimensions.create({ + heightCm: value, + lengthCm: 0, + widthCm: 0 + }); + } + f.component.settings.containerDimensions = dimensions; + } + setForm(f); + }; + + const isHeightDimensionValid = () => { + return !isNaN(Number(form.height)); + }; + + const changeWidthDimension = (event: any) => { + let f = cloneDeep(form); + f.width = event.target.value; + if (!isNaN(Number(event.target.value))) { + let dimensions = f.component.settings.containerDimensions; + let value = Number(event.target.value); + if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) { + value = value * 100; + } else if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) { + value = value * 30.48; + } + if (dimensions !== undefined && dimensions !== null) { + dimensions.widthCm = value; + } else { + dimensions = pond.Dimensions.create({ + heightCm: 0, + lengthCm: 0, + widthCm: value + }); + } + f.component.settings.containerDimensions = dimensions; + } + setForm(f); + }; + + const isWidthDimensionValid = () => { + return !isNaN(Number(form.width)); + }; + + const changeSensorDistance = (event: any) => { + let f = cloneDeep(form); + f.sensorDistance = event.target.value; + if (!isNaN(Number(event.target.value))) { + let value = Number(event.target.value); + if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) { + value = value * 100; + } else if (getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) { + value = value * 30.48; + } + f.component.settings.sensorDistanceCm = value; + } + setForm(f); + }; + + const isSensorDistanceValid = () => { + return !isNaN(Number(form.sensorDistance)); + }; + + const selectText = (event: any) => { + event.target.select(); + }; + + const findSelectedGrain = (options: Option[]): Option | undefined => { + const { component } = form; + + return options.find(o => { + let target = pond.Grain[o.value as keyof typeof pond.Grain]; + return component.settings.grainType === target; + }); + }; + + const grainSelect = () => { + let selected = findSelectedGrain(grainOptions); + return ( + + ); + }; + + const dismissDataUsageWarning = () => { + localStorage.setItem("dataUsageWarningDismissed", "true"); + setDataUsageWarningDismissed(true); + }; + + const blanketCalibration = () => { + const { component, coefficient, offset } = form; + return ( + + + + + } + label="Calibrate" + labelPlacement="top" + className={classes.switchControl} + disabled={!canEdit} + /> + + + + + + + + {describeMeasurement( + primaryMeasurement(component.settings.type), + component.settings.type, + component.settings.subtype + ).unit()} + + ) + }}> + + + ); + }; + + const describeSource = (measurementType: quack.MeasurementType): MeasurementDescriber => { + const { component } = form; + return describeMeasurement(measurementType, component.type(), component.subType()); + }; + + const availableMeasurementTypes = (type: quack.ComponentType): quack.MeasurementType[] => { + return getMeasurements(type).map(m => m.measurementType); + }; + + const measurementTypeMenuItems = () => { + return availableMeasurementTypes(form.component.type()).map(measurementType => ( + + {describeSource(measurementType).label()} + + )); + }; + + const individualCalibration = () => { + const { component, coefficients, offsets } = form; + return ( + + + + + } + label="Calibrate" + labelPlacement="top" + className={classes.switchControl} + disabled={!canEdit} + /> + + + {component.settings.calibrations.map((calibration, i) => { + return ( + + {i > 0 && } + 0 ? 12 : 8, sm: 3 }} > + { + updateCoefficients(event, i); + }} + onFocus={selectText} + margin="normal" + fullWidth + type="text" + variant="outlined" + /> + + + { + updateOffsets(event, i); + }} + onFocus={selectText} + margin="normal" + fullWidth + type="text" + variant="outlined" + /> + + + updateCalibrationTypes(event, i)} + autoFocus={false} + margin="normal" + variant="outlined" + fullWidth + InputLabelProps={{ shrink: true }}> + + All + + {measurementTypeMenuItems()} + + + + ); + })} + + + ); + }; + + const generalForm = () => { + const { + component, + measure, + report, + partiallyFilled, + coefficient, + offset, + length, + height, + width, + sensorDistance + } = form; + let reportDesc = ""; + let reportPeriod = component.settings.reportPeriodMs; + let measurementPeriod = component.settings.measurementPeriodMs; + if (reportPeriod === measurementPeriod) { + reportDesc = "Every measurement"; + } else { + reportDesc = "Independently"; + } + if (!form.report) { + reportDesc = "Not reporting"; + } + let showDataUsageWarning = + !dataUsageWarningDismissed && + form.report && + component.settings.reportPeriodMs < 300000 && + device.settings.platform === pond.DevicePlatform.DEVICE_PLATFORM_ELECTRON; + return ( + + + {/* TODO-CS: Change this to have a parameter in the extensions that gets checked rather then checking for the component type */} + {/*hasFan(component.type()) && (*/} + {/* { + component.settings.fanId = fanID; + if ( + fanID > 0 && + !component.settings.defaultMutations.includes(pond.Mutator.MUTATOR_CFM) + ) { + component.settings.defaultMutations.push(pond.Mutator.MUTATOR_CFM); + } else if (fanID === 0 || !fanID) { + component.settings.defaultMutations.splice( + component.settings.defaultMutations.indexOf(pond.Mutator.MUTATOR_CFM) + ); + } + }} + /> */} + {/* )} */} + {component.settings.type === quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE && + grainSelect()} + {component.settings.type === quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE && ( + + + + } + label={Partially Filled} + labelPlacement="top" + className={classes.switchControl} + disabled={!canEdit} + /> + + + + + + + + )} + + { + + + } + label={Measure} + labelPlacement="top" + className={classes.switchControl} + disabled={!canEdit} + /> + + } + { + + + + + + } + { + setReportExpanded(expanded)}> + }> + Report + {reportDesc} + + + + + + + } + label={Enabled} + labelPlacement="top" + className={classes.switchControl} + disabled={!canEdit} + /> + + + + + + + + + + + } + {!compMode ? ( + device.featureSupported("individualCalibrations") ? ( + individualCalibration() + ) : ( + blanketCalibration() + ) + ) : ( + + + + + } + label="Mode" + labelPlacement="top" + className={classes.switchControl} + disabled={!canEdit} + /> + + + + {compMode.mode.entryA && ( + + {compMode.mode.entryA === "select" && + compMode.mode.dictionaryA.map((elem: any) => { + if (!elem.restricted || (elem.restricted && user.hasAdmin())) { + return ( + + {elem.key} + + ); + } + return undefined; //TODO-CS: Time permitting re-think the structure of this function so that I don't have to return undefined + })} + + )} + + + {compMode.mode.entryB && ( + + {compMode.mode.entryB === "select" && + compMode.mode.dictionaryB.map((elem: any) => ( + + {elem.key} + + ))} + + )} + + + )} + + {hasDimensions(component.settings.type) && ( + + Dimensions + + + + {getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS + ? "m" + : "ft"} + + ) + }} + /> + + + + {getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS + ? "m" + : "ft"} + + ) + }} + /> + + + + {getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS + ? "m" + : "ft"} + + ) + }} + /> + + + + )} + {isMultiSensor(component.settings.type) && ( + + {getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS ? "m" : "ft"} + + ) + }} + /> + )} + {isController(component.settings.type) && ( + + + + Auto + On + Off + + + + + + + + + )} + {showDataUsageWarning && ( + + + + + + }> + High Data UsageA report period less than 5 minutes will + use a large amount of cellular data + + + + )} + + + ); + }; + + return {generalForm()}; +} diff --git a/src/component/ComponentHistory.tsx b/src/component/ComponentHistory.tsx new file mode 100644 index 0000000..31afbdb --- /dev/null +++ b/src/component/ComponentHistory.tsx @@ -0,0 +1,69 @@ +import { Box } from "@material-ui/core"; +import { useComponentAPI } from "hooks"; +import { Component, Device } from "models"; +import { pond } from "protobuf-ts/pond"; +import React from "react"; +import { or } from "utils/types"; +import { TranslateKey, TranslateValue } from "pbHelpers/Component"; +import DiffHistory, { ListResult, Record } from "common/DiffHistory"; + +interface Props { + device: Device; + component: Component; +} + +export default function ComponentHistory(props: Props) { + const componentAPI = useComponentAPI(); + + let list = (limit: number, offset: number): Promise => { + return new Promise(resolve => { + componentAPI + .listHistory(props.device.id(), props.component.key(), limit, offset) + .then((res: any) => { + let records: Record[] = or(res.data.history, []).map((record: any) => { + return { + timestamp: or(record.timestamp, ""), + user: or(record.user, ""), + data: or(record.component, {}), + status: or(record.progress, "Unknown") + } as Record; + }); + resolve({ + records: records, + total: or(res.data.total, 0), + offset: or(res.data.nextOffset, 0) + }); + }) + .catch((err: any) => { + resolve({ + records: [] as Record[], + total: 0, + offset: 0 + }); + }); + }); + }; + + let translateKey = (key: keyof any): string => { + return TranslateKey(key as keyof pond.ComponentSettings); + }; + + let translateValue = (key: keyof any, obj: any): string => { + return TranslateValue( + key as keyof pond.ComponentSettings, + pond.ComponentSettings.fromObject(obj) + ); + }; + return ( + + + + ); +} diff --git a/src/component/ComponentMode.json b/src/component/ComponentMode.json new file mode 100644 index 0000000..16cea43 --- /dev/null +++ b/src/component/ComponentMode.json @@ -0,0 +1,139 @@ +[ + { + "type": 12, + "subtype": 0, + "mode": { + "labelA": "Power Mode", + "entryA": "select", + "dictionaryA": [ + { + "key": "High Power", + "value": 1, + "restricted": false + }, + { + "key": "Low Power", + "value": 2, + "restricted": false + }, + { + "key": "Calibration", + "value": 3, + "restricted": true + } + ], + "labelB": "Number of Averages", + "entryB": "number", + "dictionaryB": [ + { + "key": "min", + "value": 1, + "restricted": false + }, + { + "key": "max", + "value": 8, + "restricted": false + } + ] + } + }, + { + "type": 28, + "subtype": 0, + "mode": { + "labelA": "Power Mode", + "entryA": "select", + "dictionaryA": [ + { + "key": "High Power", + "value": 1, + "restricted": false + }, + { + "key": "Low Power", + "value": 2, + "restricted": false + } + ], + "labelB": "Number of Averages", + "entryB": "number", + "minB": 1, + "maxB": 8 + } + }, + { + "type": 5, + "subtype": 5, + "mode": { + "labelA": "From Node Id", + "entryA": "number", + "dictionaryA": [ + { + "key": "min", + "value": 1, + "restricted": false + }, + { + "key": "max", + "value": 100, + "restricted": false + } + ], + "labelB": "To Node Id", + "entryB": "number", + "dictionaryB": [ + { + "key": "min", + "value": 1, + "restricted": false + }, + { + "key": "max", + "value": 100, + "restricted": false + } + ] + } + }, + { + "type": 5, + "subtype": 0, + "mode": { + "labelA": "Read Mode", + "entryA": "select", + "dictionaryA": [ + { + "key": "Fast Read", + "value": 1, + "restricted": false + }, + { + "key": "Standard", + "value": 2, + "restricted": false + } + ] + } + }, + { + "type": 5, + "subtype": 2, + "mode": { + "labelA": "Read Mode", + "entryA": "select", + "dictionaryA": [ + { + "key": "Fast Read", + "value": 1, + "restricted": false + }, + { + "key": "Standard", + "value": 2, + "restricted": false + } + ] + } + } +] diff --git a/src/component/ComponentOrder.tsx b/src/component/ComponentOrder.tsx new file mode 100644 index 0000000..3b8329e --- /dev/null +++ b/src/component/ComponentOrder.tsx @@ -0,0 +1,184 @@ +import { + Avatar, + Button, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + List, + ListItem, + ListItemAvatar, + ListItemIcon, + ListItemText, + ListSubheader, + Typography +} from "@material-ui/core"; +import { createStyles, makeStyles, Theme } from "@material-ui/core/styles"; +import DragIcon from "@material-ui/icons/DragHandle"; +import ResponsiveDialog from "common/ResponsiveDialog"; +import { useDeviceAPI, usePrevious, useSnackbar } from "hooks"; +import { cloneDeep } from "lodash"; +import { Component, Device } from "models"; +import { sortComponents } from "pbHelpers/Component"; +import { pond, quack } from "protobuf-ts/pond"; +import React, { useEffect, useState } from "react"; +import { DragDropContext, Draggable, Droppable, DropResult } from "react-beautiful-dnd"; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + item: { + backgroundColor: "rgba(0,0,0,0)", + "&:hover": { + backgroundColor: + theme.palette.type === "light" ? "rgba(0,0,0,0.07)" : "rgba(255,255,255,0.07)" + } + } + }) +); + +interface ItemProps { + component: Component; + index: number; +} + +function ComponentItem(props: ItemProps) { + const classes = useStyles(); + const { component, index } = props; + const name = component.name(); + return ( + + {provided => ( + + + + + {name}} /> + + {index + 1} + + + )} + + ); +} + +interface ListProps { + open: boolean; + close: (refresh: boolean) => void; + device: Device; + components: Component[]; + devicePreferences: pond.UserPreferences; +} + +const filteredComponents = (components: Component[]) => { + const componentBlacklist = [ + quack.ComponentType.COMPONENT_TYPE_POWER, + quack.ComponentType.COMPONENT_TYPE_MODEM + ]; + return components.filter(c => !componentBlacklist.includes(c.settings.type)); +}; + +export default function ComponentOrder(props: ListProps) { + const { open, close, device, devicePreferences } = props; + const deviceAPI = useDeviceAPI(); + const { error, success } = useSnackbar(); + const prevDisplayOrder = usePrevious(devicePreferences.childDisplayOrder); + const [components, setComponents] = useState( + Array.from(filteredComponents(props.components).values()).sort((a, b: Component) => + sortComponents(a, b, devicePreferences.childDisplayOrder) + ) + ); + + useEffect(() => { + if ( + props.components.length !== components.length || + prevDisplayOrder !== devicePreferences.childDisplayOrder + ) { + setComponents( + Array.from(filteredComponents(props.components).values()).sort((a, b: Component) => + sortComponents(a, b, devicePreferences.childDisplayOrder) + ) + ); + } + }, [props.components, prevDisplayOrder, devicePreferences.childDisplayOrder, components.length]); + + const updateComponentOrder = (componentOrder: Component[]) => { + let updatedPreferences = cloneDeep(devicePreferences); + updatedPreferences.childDisplayOrder = componentOrder.map(c => c.locationString()); + deviceAPI + .updatePreferences(device.id(), updatedPreferences) + .then(() => success("Successfully update the component display order")) + .catch(() => { + error("Error occured while updating the component display order"); + }) + .finally(() => close(true)); + }; + + const submit = () => { + updateComponentOrder(components); + }; + + const reorder = (components: Component[], startIndex: number, endIndex: number): Component[] => { + const result = Array.from(components); + const [removed] = result.splice(startIndex, 1); + result.splice(endIndex, 0, removed); + return result; + }; + + const onDragEnd = (result: DropResult) => { + if (!result.destination) { + return; + } + setComponents(reorder(components, result.source.index, result.destination.index)); + }; + + return ( + close(false)} maxWidth="sm" fullWidth> + + Component Display Order + + {device.name()} + + + + {components.length < 1 ? ( + No components to reorder + ) : ( + + + {provided => ( + + + Drag and Drop to change the display order + + + }> + {components.map((component, index) => ( + + ))} + {provided.placeholder} + + )} + + + )} + + + + + + + ); +} diff --git a/src/component/ComponentSettings.tsx b/src/component/ComponentSettings.tsx new file mode 100644 index 0000000..2dd24a2 --- /dev/null +++ b/src/component/ComponentSettings.tsx @@ -0,0 +1,1055 @@ +import { + Box, + Button, + Checkbox, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Divider, + FormControlLabel, + Grid2 as Grid, + IconButton, + MenuItem, + Step, + StepLabel, + Stepper, + Switch, + Tab, + Tabs, + TextField, + Theme, + Tooltip, + Typography +} from "@mui/material"; +import DeleteButton from "common/DeleteButton"; +import ResponsiveDialog from "common/ResponsiveDialog"; +import SearchSelect, { Option } from "common/SearchSelect"; +import { useComponentAPI, useDeviceAPI, usePrevious, useSnackbar } from "hooks"; +import { cloneDeep } from "lodash"; +import { Component, Device } from "models"; +import { + getFriendlyAddressTypeName, + getHumanReadableAddress, + isSingular +} from "pbHelpers/AddressType"; +import { + extension, + getAddressTypes, + GetComponentTypeOptions, + getFriendlyName, + getMeasurements, + isController +} from "pbHelpers/ComponentType"; +import { + ComponentAvailabilityMap, + DeviceAvailabilityMap, + OffsetAvailabilityMap +} from "pbHelpers/DeviceAvailability"; +import { pond } from "protobuf-ts/pond"; +import { quack } from "protobuf-ts/quack"; +import React, { useCallback, useEffect, useState } from "react"; +import { or } from "utils/types"; +import { ConfigurablePin } from "pbHelpers/AddressTypes"; +import ColourPicker from "common/ColourPicker"; +import classNames from "classnames"; +// import green from "@material-ui/core/colors/green"; +// import AddIcon from "@material-ui/icons/AddCircle"; +// import RemoveIcon from "@material-ui/icons/RemoveCircle"; +// import red from "@material-ui/core/colors/red"; +import { getContextKeys, getContextTypes } from "pbHelpers/Context"; +import ComponentForm from "./ComponentForm"; +import { green, red } from "@mui/material/colors"; +import { makeStyles } from "@mui/styles"; +import { + Remove as RemoveIcon, + Add as AddIcon, + +} from "@mui/icons-material" + +const useStyles = makeStyles((theme: Theme) => { + return ({ + stepper: { + paddingRight: 0, + paddingLeft: 0 + }, + switchControl: { + marginLeft: theme.spacing(0.25), + [theme.breakpoints.up("sm")]: { + marginLeft: theme.spacing(1) + } + }, + selectInput: { + minHeight: "48px" + }, + fullWidth: { + width: "100%" + }, + heading: { + fontSize: theme.typography.pxToRem(13), + flexBasis: "25%", + flexShrink: 0 + }, + secondaryHeading: { + fontSize: theme.typography.pxToRem(13), + color: theme.palette.text.secondary + }, + greenButton: { + color: green["600"] + }, + redButton: { + color: red["600"] + }, + noPadding: { + padding: 0 + }, + dataUsageCard: { width: "100%" }, + dataUsageHeader: { paddingBottom: 0 }, + dataUsageIcon: { color: "var(--status-warning)" }, + dataUsageContent: { paddingTop: 0 } + }) +}); + +interface Props { + device: Device; + component?: Component; + isDialogOpen: boolean; + closeDialogCallback: () => void; + availablePositions: DeviceAvailabilityMap; + availableOffsets: OffsetAvailabilityMap; //deprecated: this is no longer used with the change of cable id to be a number entry + mode?: "add" | "remove" | "update"; + refreshCallback: () => void; + canEdit: boolean; + addressTypeRestriction?: quack.AddressType; + deviceComponentPrefs?: pond.DeviceComponentPreferences; +} + +interface TabPanelProps { + children?: React.ReactNode; + index: any; + value: any; +} + +function TabPanelMine(props: TabPanelProps) { + const { children, value, index, ...other } = props; + + return ( + + ); +} + +export default function ComponentSettings(props: Props) { + const classes = useStyles(); + const componentAPI = useComponentAPI(); + const deviceAPI = useDeviceAPI(); + const { error, warning, success } = useSnackbar(); + const { + device, + isDialogOpen, + closeDialogCallback, + availablePositions, + mode, + refreshCallback, + canEdit, + addressTypeRestriction, + deviceComponentPrefs + } = props; + const prevComponent = usePrevious(props.component); + const prevIsDialogOpen = usePrevious(isDialogOpen); + const steps = [0, 1]; + const [activeStep, setActiveStep] = useState(0); + const [componentType, setComponentType] = useState