diff --git a/package-lock.json b/package-lock.json index 84cfa8a..33c02b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "weather-anomaly-tool", - "version": "1.0.0", + "version": "2.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "weather-anomaly-tool", - "version": "1.0.0", + "version": "2.0.0", "license": "GPL-3.0", "dependencies": { "@react-leaflet/core": "1.0.2", @@ -28,7 +28,8 @@ "react-scripts": "^4.0.3", "react-test-renderer": "^16.0.0", "svg-loaders-react": "^2.2.1", - "url-join": "^2.0.2" + "url-join": "^2.0.2", + "zustand": "^4.4.6" } }, "node_modules/@babel/code-frame": { @@ -10601,9 +10602,11 @@ } }, "node_modules/immer": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/immer/-/immer-8.0.1.tgz", - "integrity": "sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.0.3.tgz", + "integrity": "sha512-pwupu3eWfouuaowscykeckFmVTpqbzW+rXFCX8rQLkZzM9ftBmU/++Ra+o+L27mz03zJTlyV4UUr+fdKNffo4A==", + "optional": true, + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -16528,6 +16531,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/react-dev-utils/node_modules/immer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-8.0.1.tgz", + "integrity": "sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/react-dev-utils/node_modules/loader-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz", @@ -22053,6 +22065,41 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.4.6.tgz", + "integrity": "sha512-Rb16eW55gqL4W2XZpJh0fnrATxYEG3Apl2gfHTyDSE965x/zxslTikpNch0JgNjJA9zK6gEFW8Fl6d1rTZaqgg==", + "dependencies": { + "use-sync-external-store": "1.2.0" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/zustand/node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } } }, "dependencies": { @@ -30036,9 +30083,11 @@ "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==" }, "immer": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/immer/-/immer-8.0.1.tgz", - "integrity": "sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA==" + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.0.3.tgz", + "integrity": "sha512-pwupu3eWfouuaowscykeckFmVTpqbzW+rXFCX8rQLkZzM9ftBmU/++Ra+o+L27mz03zJTlyV4UUr+fdKNffo4A==", + "optional": true, + "peer": true }, "import-cwd": { "version": "2.1.0", @@ -34590,6 +34639,11 @@ "slash": "^3.0.0" } }, + "immer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-8.0.1.tgz", + "integrity": "sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA==" + }, "loader-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz", @@ -38976,6 +39030,22 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" + }, + "zustand": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.4.6.tgz", + "integrity": "sha512-Rb16eW55gqL4W2XZpJh0fnrATxYEG3Apl2gfHTyDSE965x/zxslTikpNch0JgNjJA9zK6gEFW8Fl6d1rTZaqgg==", + "requires": { + "use-sync-external-store": "1.2.0" + }, + "dependencies": { + "use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "requires": {} + } + } } } } diff --git a/package.json b/package.json index 9064d51..78db0e9 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "react-scripts": "^4.0.3", "react-test-renderer": "^16.0.0", "svg-loaders-react": "^2.2.1", - "url-join": "^2.0.2" + "url-join": "^2.0.2", + "zustand": "^4.4.6" }, "scripts": { "start": "react-scripts start", diff --git a/src/components/controls/DatasetSelector/DatasetSelector.js b/src/components/controls/DatasetSelector/DatasetSelector.js index 3a2be40..61b6a82 100644 --- a/src/components/controls/DatasetSelector/DatasetSelector.js +++ b/src/components/controls/DatasetSelector/DatasetSelector.js @@ -1,30 +1,35 @@ import React from 'react'; -import PropTypes from 'prop-types'; import flow from 'lodash/fp/flow'; import keys from 'lodash/fp/keys'; import map from 'lodash/fp/map'; import DatasetLabel from '../../datasets/DatasetLabel'; import RadioButtonSelector from '../RadioButtonSelector'; -import { useConfigContext } from '../../main/ConfigContext'; +import { useStore } from '../../../state-store'; -function DatasetSelector(props) { - const config = useConfigContext(); +export default function DatasetSelector(props) { + const config = useStore(state => state.config); + + // TODO: Memoize const options = flow( keys, map(dataset => ({ value: dataset, label: }) ), )(config.datasets); + + const dataset = useStore(state => state.dataset); + const setDataset = useStore(state => state.setDataset); + return ( - + ); } - -DatasetSelector.propTypes = { - value: PropTypes.string, - onChange: PropTypes.func.isRequired, -}; - -export default DatasetSelector; diff --git a/src/components/controls/MonthIncrementDecrement/MonthIncrementDecrement.js b/src/components/controls/MonthIncrementDecrement/MonthIncrementDecrement.js new file mode 100644 index 0000000..e96f57d --- /dev/null +++ b/src/components/controls/MonthIncrementDecrement/MonthIncrementDecrement.js @@ -0,0 +1,21 @@ +import React from 'react'; +import IncrementDecrement from '../../controls/IncrementDecrement'; +import { useStore } from '../../../state-store'; + +export default function MonthIncrementDecrement(props) { + const config = useStore(state => state.config); + const incrementMonth = useStore(state => state.incrementMonth); + const isDataLoading = useStore(state => state.isDataLoading()); + + return ( + + + ) +} \ No newline at end of file diff --git a/src/components/controls/MonthIncrementDecrement/package.json b/src/components/controls/MonthIncrementDecrement/package.json new file mode 100644 index 0000000..8e141f8 --- /dev/null +++ b/src/components/controls/MonthIncrementDecrement/package.json @@ -0,0 +1,6 @@ +{ + "name": "MonthIncrementDecrement", + "version": "0.0.0", + "private": true, + "main": "./MonthIncrementDecrement.js" +} \ No newline at end of file diff --git a/src/components/controls/MonthSelector/MonthSelector.js b/src/components/controls/MonthSelector/MonthSelector.js index 13a40a2..81fed68 100644 --- a/src/components/controls/MonthSelector/MonthSelector.js +++ b/src/components/controls/MonthSelector/MonthSelector.js @@ -1,18 +1,24 @@ import React from 'react'; import ThrottledInputRange from '../ThrottledInputRange'; +import { useStore } from '../../../state-store'; const monthNames = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split(' '); const formatLabel = value => monthNames[value]; -function MonthSelector(props) { +export default function MonthSelector(props) { + const date = useStore(state => state.date); + const setMonth = useStore(state => state.setMonth); + const isDataLoading = useStore(state => state.isDataLoading()); + return ( ); } - -export default MonthSelector; diff --git a/src/components/controls/ThrottledInputRange/ThrottledInputRange.js b/src/components/controls/ThrottledInputRange/ThrottledInputRange.js index a679840..19be72d 100644 --- a/src/components/controls/ThrottledInputRange/ThrottledInputRange.js +++ b/src/components/controls/ThrottledInputRange/ThrottledInputRange.js @@ -7,6 +7,8 @@ import InputRange from 'react-input-range'; function ThrottledInputRange({ value, onChange, ...rest }) { const [intermediateValue, setIntermediateValue] = useState(value); + // TODO: This is probably better as useMemo. + // Update intermediate value whenever external value changes. useEffect(() => { setIntermediateValue(value); }, [value]); diff --git a/src/components/controls/VariableSelector/VariableSelector.js b/src/components/controls/VariableSelector/VariableSelector.js index 3b30895..3105446 100644 --- a/src/components/controls/VariableSelector/VariableSelector.js +++ b/src/components/controls/VariableSelector/VariableSelector.js @@ -1,33 +1,38 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { Tooltip } from 'react-bootstrap'; import keys from 'lodash/fp/keys'; -import { useConfigContext } from '../../main/ConfigContext'; import RadioButtonSelector from '../RadioButtonSelector'; import VariableLabel from '../../variables/VariableLabel'; +import { useStore } from '../../../state-store'; function VariableSelector(props) { - const config = useConfigContext(); + const config = useStore(state => state.config); + + // TODO: Memoize variableOptions const variableKeys = keys(config?.variables); const variableOptions = variableKeys.map( value => ({ value, label: }) ); + + const variable = useStore(state => state.variable); + const setVariable = useStore(state => state.setVariable); + const isDataLoading = useStore(state => state.isDataLoading()); + return ( - + ); } -VariableSelector.propTypes = { - disabled: PropTypes.bool, - // Is control disabled - value: PropTypes.string, - // Current value of control - onChange: PropTypes.func, - // Callback when new option selected -}; - VariableSelector.tooltips = { precip: Monthly total precipitation, tmin: Monthly average of daily minimum diff --git a/src/components/controls/YearIncrementDecrement/YearIncrementDecrement.js b/src/components/controls/YearIncrementDecrement/YearIncrementDecrement.js new file mode 100644 index 0000000..c8ddfd4 --- /dev/null +++ b/src/components/controls/YearIncrementDecrement/YearIncrementDecrement.js @@ -0,0 +1,21 @@ +import React from 'react'; +import IncrementDecrement from '../../controls/IncrementDecrement'; +import { useStore } from '../../../state-store'; + +export default function YearIncrementDecrement(props) { + const config = useStore(state => state.config); + const incrementYear = useStore(state => state.incrementYear); + const isDataLoading = useStore(state => state.isDataLoading()); + + return ( + + + ) +} \ No newline at end of file diff --git a/src/components/controls/YearIncrementDecrement/package.json b/src/components/controls/YearIncrementDecrement/package.json new file mode 100644 index 0000000..cae82b3 --- /dev/null +++ b/src/components/controls/YearIncrementDecrement/package.json @@ -0,0 +1,6 @@ +{ + "name": "YearIncrementDecrement", + "version": "0.0.0", + "private": true, + "main": "./YearIncrementDecrement.js" +} \ No newline at end of file diff --git a/src/components/controls/YearSelector/YearSelector.js b/src/components/controls/YearSelector/YearSelector.js index 9287dc7..e8ab056 100644 --- a/src/components/controls/YearSelector/YearSelector.js +++ b/src/components/controls/YearSelector/YearSelector.js @@ -1,9 +1,21 @@ import React from 'react'; import ThrottledInputRange from '../ThrottledInputRange'; +import { latestPossibleDataDate, useStore } from '../../../state-store'; function YearSelector(props) { + const date = useStore(state => state.date); + const setYear = useStore(state => state.setYear); + const isDataLoading = useStore(state => state.isDataLoading()); + return ( - + ); } diff --git a/src/components/datasets/DatasetLabel/DatasetLabel.js b/src/components/datasets/DatasetLabel/DatasetLabel.js index dffcf5c..c4ddd74 100644 --- a/src/components/datasets/DatasetLabel/DatasetLabel.js +++ b/src/components/datasets/DatasetLabel/DatasetLabel.js @@ -1,8 +1,8 @@ import React from 'react'; -import { useConfigContext } from '../../main/ConfigContext'; +import { useStore } from '../../../state-store'; export default function DatasetLabel({ dataset }) { - const config = useConfigContext(); + const config = useStore(state => state.config); return ( state.initialize); + const isConfigLoaded = useStore(state => state.isConfigLoaded()); + const configError = useStore(state => state.configError); + + // Initialize must be invoked before any items dependent on config. + // Done once, at startup. + useEffect(() => { + initialize({ + configOpts: { + dfault: { + // TODO + }, + requiredKeys: [ + // TODO + ], + } + }); + }, []); + + if (configError !== null) { // TODO: Improve presentation return (
-
{configErrorMessage}
+
{configError}
) } - if (config === null) { + if (!isConfigLoaded) { // TODO: Replace with spinner return
Loading configuration...
} return ( - - -
- - - + +
+ + ); } diff --git a/src/components/main/ConfigContext.js b/src/components/main/ConfigContext.js deleted file mode 100644 index 0fdee80..0000000 --- a/src/components/main/ConfigContext.js +++ /dev/null @@ -1,88 +0,0 @@ -// This module provides a React context for app configuration values, -// a hook for fetching its value from a YAML file in the public folder , -// and a hook for using the context value in a functional component. -import React, { useContext, useEffect, useState } from 'react'; -import axios from 'axios'; -import yaml from "js-yaml"; -import filter from "lodash/fp/filter"; -import isUndefined from "lodash/fp/isUndefined"; - - -// The context -const ConfigContext = React.createContext({}); -export default ConfigContext; - - -// Custom hook for fetching configuration from public/config.yaml. -// This hook returns two state variables, `[config, errorMessage]`. -// -// If the configuration file is successfully fetched, parsed as YAML, and -// the resulting configuration object contains all required keys, the `config` -// state variable is set to that value and `errorMessage` remains null. -// -// If not (i.e., there are errors), `config` remains null and `errorMessage` -// is set to an appropriate message (which may be a string or a React object). -// -// This hook should be used only by the top-level component, and the returned -// `config` used to set the value provided by `ConfigContext.Provider`. (For -// specific usage, see component `App`.) All other components should access -// app configuration using the hook `useConfigContext`. -export function useFetchConfigContext({ - defaultConfig = {}, - requiredConfigKeys = [], -}) { - const [config, setConfig] = useState(null); - const [errorMsg, setErrorMsg] = useState(null); - - useEffect(() => { - axios.get(`${process.env.PUBLIC_URL}/config.yaml`) - .then(response => { - // Extend default config with values loaded from config.yaml - let cfg; - try { - const customConfig = yaml.load(response.data); - cfg = { ...defaultConfig, ...customConfig }; - } catch (e) { - setErrorMsg( -
Error loading config.yaml:
{e.toString()}
- ); - return; - } - - // Check for required config keys (we don't check value types, yet) - const missingRequiredKeys = filter( - key => isUndefined(cfg[key]), requiredConfigKeys - ); - if (missingRequiredKeys.length > 0) { - setErrorMsg( - `Error in config.yaml: The following keys must have values, - but do not: ${missingRequiredKeys}` - ); - return; - } - - // TODO: Is there really any value in this? - // Alternatively, just transfer everything in process.env here. - // None of that makes much sense ... - // Extend config with some env var values - cfg.appVersion = process.env.REACT_APP_APP_VERSION ?? "unknown"; - - // Update the config state. - setConfig(cfg); - }) - .catch(error => { - setErrorMsg( -
Error fetching configuration:
{error.toString()}
- ); - }); - }, []); - - return [config, errorMsg]; -} - - -// Custom hook to make it slightly simpler to access the config context from a -// client component. -export function useConfigContext() { - return useContext(ConfigContext); -} diff --git a/src/components/main/Header/Header.js b/src/components/main/Header/Header.js index ca8a869..400d436 100644 --- a/src/components/main/Header/Header.js +++ b/src/components/main/Header/Header.js @@ -3,10 +3,10 @@ import { Row, Col } from 'react-bootstrap'; import logo from './logo.png'; import './Header.css'; -import { useConfigContext } from '../ConfigContext'; +import { useStore } from '../../../state-store'; export default function Header() { - const config = useConfigContext(); + const config = useStore(state => state.config); return ( diff --git a/src/components/main/Tool/Tool.js b/src/components/main/Tool/Tool.js index f14c87e..ea51eba 100644 --- a/src/components/main/Tool/Tool.js +++ b/src/components/main/Tool/Tool.js @@ -1,97 +1,24 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { Col, Row } from 'react-bootstrap'; -import moment from 'moment'; import DatasetSelector from '../../controls/DatasetSelector' import VariableSelector from '../../controls/VariableSelector' import YearSelector from '../../controls/YearSelector'; import MonthSelector from '../../controls/MonthSelector'; -import IncrementDecrement from '../../controls/IncrementDecrement'; -import ColourScale from '../../map/ColourScale'; -import DataMap from '../../map/DataMap'; -import VariableTitle from '../../variables/VariableTitle'; +import MonthIncrementDecrement from '../../controls/MonthIncrementDecrement'; +import YearIncrementDecrement from '../../controls/YearIncrementDecrement'; +import DataDisplay from '../../map/DataDisplay'; -import { getBaselineData, getLastDateWithDataBefore, getMonthlyData } - from '../../../data-services/weather-anomaly-data-service'; -import { useConfigContext } from '../ConfigContext'; +import { useStore } from '../../../state-store'; import 'react-input-range/lib/css/index.css'; import './Tool.css'; -// Note: We use package `moment` for date arithmetic. It is excellent but it -// *mutates* its objects. We are using functional components, -// which require values whose identity changes when their value is changed, -// i.e., a new object. Therefore every change to a `moment` date object should -// be preceded by `.clone()`; for example, `y = x.clone().subtract(1, 'month')` -// yields a new moment object with a value 1 month before the original `x` -// object. `x` is unchanged by this operation, and `y` is a different object -// than `x`. - -// Compute likely latest possible date of available data = current date - 15 d. -// This allows for cron jobs that run in first half of month. -// Subtract fewer/more days if cron jobs run earlier/later in month. -// But it is not guaranteed that there is data for this date; that can only be -// determined by consulting the backend. -const latestPossibleDataDate = moment().subtract(15, 'days'); - export default function Tool() { - const config = useConfigContext(); - const wadsUrl = config.backends.weatherAnomalyDataService; - - const [variable, setVariable] = useState(config.ui.variableSelector.initial); - const [dataset, setDataset] = useState(config.ui.datasetSelector.initial); - const [date, setDate] = useState(latestPossibleDataDate); - const [baseline, setBaseline] = useState(null); - const [monthly, setMonthly] = useState(null); - - // Determine latest date with data, and set date to it. This happens once, - // on first render. - useEffect(() => { - setBaseline(null); - setMonthly(null); - getLastDateWithDataBefore(variable, date, wadsUrl) - .then(date => { - setDate(date); - }); - }, []); - - // When variable or date changes, get data. - // (Both datasets are is retrieved for all values of `dataset`. This could - // be refined to get only the dataset(s) required by the value of `dataset`.) - // Consider splitting this into two separate effects, with an if on `dataset`. - useEffect(() => { - setBaseline(null); - setMonthly(null); - getBaselineData(variable, date, wadsUrl) - .then(r => { - setBaseline(r.data); - }); - getMonthlyData(variable, date, wadsUrl) - .then(r => { - setMonthly(r.data); - }); - }, [variable, date]); - - const handleChangeMonth = (month) => { - setDate((date) => date.clone().month(month)); - }; - - const handleChangeYear = (year) => { - setDate((date) => date.clone().year(year)); - }; - - const handleIncrementYear = (by) => { - setDate((date) => date.clone().add(by, 'year')); - }; - - const handleIncrementMonth = (by) => { - setDate((date) => date.clone().add(by, 'month')); - }; - - const isDataLoading = baseline === null || monthly === null; - const isBaselineDataset = dataset === 'baseline'; + const isBaselineDataset = useStore(state => state.isBaselineDataset()); + // TODO: Move into config? const displayColWidths = { xs: 12, md: "auto" }; const rowSpacing = "mt-3" @@ -102,92 +29,40 @@ export default function Tool() { Display - + - + for - + - + {!isBaselineDataset && - + - + } - - - - - - - - - - - - - + diff --git a/src/components/map/ColourScale/ColourScale.js b/src/components/map/ColourScale/ColourScale.js index d56339a..9c5ee95 100644 --- a/src/components/map/ColourScale/ColourScale.js +++ b/src/components/map/ColourScale/ColourScale.js @@ -1,6 +1,6 @@ import React from 'react'; -import { useConfigContext } from '../../main/ConfigContext'; +import { useStore } from '../../../state-store'; export default function ColourScale({ @@ -23,7 +23,7 @@ export default function ColourScale({ return csItem.annotation; }, }) { - const config = useConfigContext(); + const config = useStore(state => state.config); const colourScale = config.colourScales[variable][dataset]; const numItems = colourScale.length; diff --git a/src/components/map/DataDisplay/DataDisplay.js b/src/components/map/DataDisplay/DataDisplay.js new file mode 100644 index 0000000..26019aa --- /dev/null +++ b/src/components/map/DataDisplay/DataDisplay.js @@ -0,0 +1,38 @@ +import React from 'react'; +import { Col, Row } from 'react-bootstrap'; + +import ColourScale from '../../map/ColourScale'; +import DataMap from '../../map/DataMap'; +import VariableTitle from '../../variables/VariableTitle'; +import { useStore } from '../../../state-store'; + +export default function DataDisplay() { + const variable = useStore(state => state.variable); + const dataset = useStore(state => state.dataset); + const baseline = useStore(state => state.baseline); + const monthly = useStore(state => state.monthly); + const hasValidState = useStore(state => state.hasValidState()); + + return hasValidState && ( + <> + + + + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/src/components/map/DataDisplay/package.json b/src/components/map/DataDisplay/package.json new file mode 100644 index 0000000..d6ab28f --- /dev/null +++ b/src/components/map/DataDisplay/package.json @@ -0,0 +1,6 @@ +{ + "name": "DataDisplay", + "version": "0.0.0", + "private": true, + "main": "./DataDisplay.js" +} \ No newline at end of file diff --git a/src/components/map/DataMap/DataMap.js b/src/components/map/DataMap/DataMap.js index b361f66..9978463 100644 --- a/src/components/map/DataMap/DataMap.js +++ b/src/components/map/DataMap/DataMap.js @@ -10,14 +10,14 @@ import compact from 'lodash/fp/compact'; import { BCBaseMap } from 'pcic-react-leaflet-components'; import './DataMap.css'; -import { useConfigContext } from '../../main/ConfigContext'; import MapSpinner from '../MapSpinner'; import StationDataMarkers from '../StationDataMarkers'; import StationLocationMarkers from '../StationLocationMarkers'; +import { useStore } from '../../../state-store'; export default function DataMap({ dataset, variable, monthly, baseline }) { - const config = useConfigContext(); + const config = useStore(state => state.config); const stationsForDataset = useMemo( () => { diff --git a/src/components/map/StationPopup/StationPopup.js b/src/components/map/StationPopup/StationPopup.js index 4d3148a..67a61a0 100644 --- a/src/components/map/StationPopup/StationPopup.js +++ b/src/components/map/StationPopup/StationPopup.js @@ -4,7 +4,7 @@ import { Popup } from 'react-leaflet' import VariableLabel from '../../variables/VariableLabel'; import VariableUnits from '../../variables/VariableUnits'; -import { useConfigContext } from '../../main/ConfigContext'; +import { useStore } from '../../../state-store'; export default function StationPopup({ @@ -24,7 +24,7 @@ export default function StationPopup({ departure, }, }) { - const config = useConfigContext(); + const config = useStore(state => state.config); const units = ; const decimalPlaces = config?.variables?.[variable]?.decimalPlaces; diff --git a/src/components/variables/VariableLabel/VariableLabel.js b/src/components/variables/VariableLabel/VariableLabel.js index 667b84e..5fd210a 100644 --- a/src/components/variables/VariableLabel/VariableLabel.js +++ b/src/components/variables/VariableLabel/VariableLabel.js @@ -1,8 +1,8 @@ import React from 'react'; -import { useConfigContext } from '../../main/ConfigContext'; +import { useStore } from '../../../state-store'; export default function VariableLabel({ variable }) { - const config = useConfigContext(); + const config = useStore(state => state.config); return ; diff --git a/src/components/variables/VariableTitle/VariableTitle.js b/src/components/variables/VariableTitle/VariableTitle.js index 6743e73..167054d 100644 --- a/src/components/variables/VariableTitle/VariableTitle.js +++ b/src/components/variables/VariableTitle/VariableTitle.js @@ -1,3 +1,7 @@ +// Variable title: Long-form description of variable for titling displays of +// data. It is generic, meaning it does not subscribe to the state store, but +// accepts arbitrary inputs for these values. + import React from 'react'; import compact from 'lodash/fp/compact'; import flow from 'lodash/fp/flow'; @@ -6,7 +10,6 @@ import { alternate } from '../../utils'; import VariableLabel from '../VariableLabel'; import VariableUnits from '../VariableUnits'; import DatasetLabel from '../../datasets/DatasetLabel'; -import { useConfigContext } from '../../main/ConfigContext'; export default function VariableTitle({ diff --git a/src/components/variables/VariableUnits/VariableUnits.js b/src/components/variables/VariableUnits/VariableUnits.js index 80b6cad..c7c8cbc 100644 --- a/src/components/variables/VariableUnits/VariableUnits.js +++ b/src/components/variables/VariableUnits/VariableUnits.js @@ -1,8 +1,8 @@ import React from 'react'; -import { useConfigContext } from '../../main/ConfigContext'; +import { useStore } from '../../../state-store'; export default function VariableUnits({ variable }) { - const config = useConfigContext(); + const config = useStore(state => state.config); return ( ({ + // States + config: null, + configError: null, + + variable: null, // config.ui.variableSelector.initial + dataset: null, // config.ui.datasetSelector.initial + baseline: null, + monthly: null, + date: latestPossibleDataDate, // TODO: Put subtraction value in config + + // Actions + + // Important: Wrap in useEffect + // Load configuration + // This should not be called except by initialize. + _loadConfig: ({ dfault = {}, requiredKeys = [] }) => { + return axios.get(`${process.env.PUBLIC_URL}/config.yaml`) + .then(response => { + // Extend default config with values loaded from config.yaml + let config; + try { + const customConfig = yaml.load(response.data); + config = { ...dfault, ...customConfig }; + } catch (error) { + set({ + configError: ( +
Error parsing config.yaml:
{error.toString()}
+ ) + }); + throw error; + } + + // Check for required config keys (we don't check value types, yet) + const missingRequiredKeys = filter( + key => isUndefined(config[key]), requiredKeys + ); + if (missingRequiredKeys.length > 0) { + const configErrorMsg = ( + `Error in config.yaml: The following keys must have values, + but do not: ${missingRequiredKeys}` + ); + set({ configError: (
{configErrorMsg}
) }); + throw new Error(configErrorMsg); + } + + // TODO: Is there really any value in this? + // Alternatively, just transfer everything in process.env here. + // None of that makes much sense ... + // Extend config with some env var values + config.appVersion = process.env.REACT_APP_APP_VERSION ?? "unknown"; + + // Update the config state. + set({ config }); + }) + .catch(error => { + set({ + configError: ( +
Error fetching configuration:
{error.toString()}
+ ) + }); + throw error; + }); + }, + + isConfigLoaded: () => get().config !== null, + + // Important: Wrap in useEffect + // Load config and initialize state. + initialize: ({ configOpts = { dfault: {}, requiredKeys: [] } }) => { + // TODO: This can probably be done more nicely with async/await. + get()._loadConfig(configOpts).then(() => { + // TODO: return config from _loadConfig as well as setting + // state.config. Would be neater. + const config = get().config; + const wadsUrl = config.backends.weatherAnomalyDataService; + const variable = config.ui.variableSelector.initial; + const dataset = config.ui.datasetSelector.initial; + set({ variable, dataset }); + getLastDateWithDataBefore(variable, latestPossibleDataDate, wadsUrl) + .then(date => { + // Note: This will also fetch the data for this date (and variable). + // We only want to do this once, so we don't call setVariable also. + get().setDate(date); + }); + }); + }, + + hasValidState: () => + get().variable !== null + && get().dataset !== null + && get().date !== null, + + // Important: Wrap in useEffect + // Set the variable and load the data associated with it. + setVariable: (variable) => { + set({ variable }); + get().getData(); + }, + + setDataset: dataset => { + set({ dataset }); + }, + + // Important: Wrap in useEffect + // Set the date and load the data associated with it. + setDate: (date) => { + set({ date }); + get().getData(); + }, + + // Important: Wrap in useEffect + // Fetch baseline and monthly data for current setting of variable and date. + getData: () => { + const wadsUrl = get().config.backends.weatherAnomalyDataService; + set({ baseline: null, monthly: null }); + getBaselineData(get().variable, get().date, wadsUrl) + .then(r => { + set({ baseline: r.data }); + }); + getMonthlyData(get().variable, get().date, wadsUrl) + .then(r => { + set({ monthly: r.data }); + }); + }, + + isDataLoading: () => get().baseline === null || get().monthly === null, + + isBaselineDataset: () => get().dataset === 'baseline', + + setYear: year => { + get().setDate(get().date.clone().year(year)); + }, + + incrementYear: by => { + get().setDate(get().date.clone().add(by, 'year')); + }, + + setMonth: month => { + get().setDate(get().date.clone().month(month)); + }, + + incrementMonth: by => { + get().setDate(get().date.clone().add(by, 'month')); + }, + + setBaseline: baseline => set({ baseline }), +})) \ No newline at end of file