diff --git a/package-lock.json b/package-lock.json index 9b8d688..b6f554d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "decc_dashboard", "version": "0.1.0", "dependencies": { + "@auth0/auth0-react": "^2.2.4", + "@auth0/auth0-spa-js": "^2.1.3", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@material-ui/core": "^4.12.3", @@ -89,6 +91,23 @@ "node": ">=6.0.0" } }, + "node_modules/@auth0/auth0-react": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@auth0/auth0-react/-/auth0-react-2.2.4.tgz", + "integrity": "sha512-l29PQC0WdgkCoOc6WeMAY26gsy/yXJICW0jHfj0nz8rZZphYKrLNqTRWFFCMJY+sagza9tSgB1kG/UvQYgGh9A==", + "dependencies": { + "@auth0/auth0-spa-js": "^2.1.3" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17 || ^18", + "react-dom": "^16.11.0 || ^17 || ^18" + } + }, + "node_modules/@auth0/auth0-spa-js": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.1.3.tgz", + "integrity": "sha512-NMTBNuuG4g3rame1aCnNS5qFYIzsTUV5qTFPRfTyYFS1feS6jsCBR+eTq9YkxCp1yuoM2UIcjunPaoPl77U9xQ==" + }, "node_modules/@babel/code-frame": { "version": "7.22.10", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.10.tgz", diff --git a/package.json b/package.json index 7a33da1..93dc623 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "version": "0.1.0", "private": true, "dependencies": { + "@auth0/auth0-react": "^2.2.4", + "@auth0/auth0-spa-js": "^2.1.3", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@material-ui/core": "^4.12.3", diff --git a/src/Dashboard.js b/src/Dashboard.js index 0321ccd..8601183 100644 --- a/src/Dashboard.js +++ b/src/Dashboard.js @@ -1,81 +1,259 @@ import React from "react"; import { Routes, Route, Link, HashRouter } from "react-router-dom"; -import { cloneDeep, has, set} from "lodash"; - +import { cloneDeep, has, set, get } from "lodash"; +import { Button, MenuItem } from "@mui/material"; +import LaunchIcon from "@mui/icons-material/Launch"; +import ProtectedRoute from "./ProtectedRoute/ProtectedRoute"; import ControlPanel from "./components/ControlPanel/ControlPanel"; -import OverlayContainer from "./components/OverlayContainer/OverlayContainer"; - -import Overlay from "./components/Overlay/Overlay"; import FAQ from "./components/FAQ/FAQ"; import LiveData from "./components/LiveData/LiveData"; import Explainer from "./components/Explainer/Explainer"; - -import { importSiteImages } from "./util/helpers"; +import { createSourceKey } from "./util/helpers"; import styles from "./Dashboard.module.css"; -// Site description information -import siteInfoJSON from "./data/siteInfo.json"; -import deccMeasData from "./data/dash_data_test_200_complete.json"; -import { Button, MenuItem } from "@mui/material"; -import LaunchIcon from '@mui/icons-material/Launch'; +async function retrieveJSON(url) { + return await (await fetch(url)).json(); +} class Dashboard extends React.Component { constructor(props) { super(props); + let defaultSite = null; + let defaultSpecies = null; + let defaultInlet = null; + let defaultInstrument = null; + let defaultNetwork = null; + let defaultSourceKey = null; + + // TODO - read in data from dashboard_config.json + // These settings will control how the interface is built + // so users select data by site or by site inlets + this.state = { error: null, isLoaded: false, showSidebar: false, - selectedDate: 0, - processedData: {}, - dataKeys: {}, selectedKeys: {}, - footprintView: true, emptySelection: true, overlayOpen: false, overlay: null, - plotType: "footprint", layoutMode: "dashboard", colours: {}, + dataStore: {}, + defaultSite: defaultSite, + defaultSpecies: defaultSpecies, + defaultInlet: defaultInlet, + defaultInstrument: defaultInstrument, + defaultNetwork: defaultNetwork, + defaultSourceKey: defaultSourceKey, }; - // Build the site info for the overlays - this.buildSiteInfo(); + this.dataRepoURL = "https://raw.githubusercontent.com/openghg/temp_data_dashboard/main/"; - // Select the data - this.dataSelector = this.dataSelector.bind(this); - // Selects the dates this.sourceSelector = this.sourceSelector.bind(this); this.toggleOverlay = this.toggleOverlay.bind(this); this.setOverlay = this.setOverlay.bind(this); this.speciesSelector = this.speciesSelector.bind(this); this.clearSources = this.clearSources.bind(this); this.toggleSidebar = this.toggleSidebar.bind(this); - this.setSiteOverlay = this.setSiteOverlay.bind(this); } - buildSiteInfo() { - const siteImages = importSiteImages(); - - let siteData = {}; - for (const site of Object.keys(siteInfoJSON)) { - try { - siteData[site] = {}; - siteData[site]["image"] = siteImages[site]; - siteData[site]["description"] = siteInfoJSON[site]["description"]; - } catch (error) { - console.error(error); + // These handle the initial setup of the app + + /** + * Converts data to the format required by Plotly + * + * @param {object} data - Data object + * + */ + toPlotly(data) { + console.log("Processing datas for plotly"); + const x_timestamps = Object.keys(data); + const x_values = x_timestamps.map((d) => new Date(parseInt(d))); + const y_values = Object.values(data); + + return { + x_values: x_values, + y_values: y_values, + }; + } + + /** + * Adds data to the data store, converting it to the structure required by Plotly + * + * @param {string} species - Species + * @param {string} sourceKey - Source key + * @param {string} data - Data that's been passed through the to_plotly function + * + */ + addDataToStore(sourceKey, data) { + const forPlotly = this.toPlotly(data); + // Now update the current dataStore with the new data + this.setState((prevState) => { + set(prevState.dataStore, sourceKey, forPlotly); + return prevState; + }); + } + + /** + * Retrieves data from the given URL and processes it into a format + * plotly can read + * + * @param {string} sourceKey - Source key + * + */ + retrieveData(sourceKey) { + const filename = get(this.state.filenameLookup, sourceKey, null); + if (filename === null) { + console.error(`No filename available for ${sourceKey}`); + return; + } + + const currentVal = get(this.state.dataStore, sourceKey); + if (currentVal !== null) { + console.log(`We already have data for ${sourceKey}`); + return; + } + + const url = new URL(filename, this.dataRepoURL).href; + + console.log(`Retrieving data from ${url}`); + retrieveJSON(url).then((result) => { + this.addDataToStore(sourceKey, result); + }); + } + + /** + * Create the data structure used to create the plots Plotly can read + * We create the dataStore object which has the following structure + * + * dataStore = { + * "species": { + * "network_site_inlet_instrument": {"x_values": [1,2,3], "y_values": [1,2,3]} + * } + * } + * Only the default source's data will be retrieved, all others will be assigned a null + */ + populateAndRetrieve(metadata) { + let defaultSpecies = null; + let defaultSite = null; + let defaultInlet = null; + // Not sure if we need default instrument but + let defaultNetwork = null; + // Don't change this, it's handled in the constructor by reading defaults.json + // otherwise a default is selected from the first + let defaultSourceKey = this.state.defaultSourceKey; + + // Store the data itself + let dataStore = {}; + // Store the metadata for each source + let metaStore = {}; + // Store the structure to allow easy building of the interface dynamically + let siteStructure = {}; + // Filename lookup for dynamic retrieval + let filenameLookup = {}; + + try { + for (const [species, networkData] of Object.entries(metadata)) { + if (defaultSpecies === null) defaultSpecies = species; + for (const [network, siteData] of Object.entries(networkData)) { + if (defaultNetwork === null) defaultNetwork = network; + for (const [site, inletData] of Object.entries(siteData)) { + if (defaultSite === null) defaultSite = site; + for (const [inlet, instrumentData] of Object.entries(inletData)) { + if (defaultInlet === null) defaultInlet = inlet; + for (const [instrument, fileMetadata] of Object.entries(instrumentData)) { + // The complete source key should always have the species at the start + // Then we use the lodash set, get etc commands to easily access the objects + const completeSourceKey = createSourceKey(species, network, site, inlet, instrument); + // We'll use this to create a lightweight structure for the creation of the interface + const nestedSourceKey = `${species}.${network}.${site}.${inlet}.${instrument}`; + const filepath = fileMetadata["filepath"]; + const sourceMetadata = fileMetadata["metadata"]; + + // We don't setState here as we need to use defaultSourceKey below + // and setState runs asynchronously so may not update the state value by the time + // we get to where we need it + if (defaultSourceKey === null) { + defaultSourceKey = completeSourceKey; + } + + set(dataStore, completeSourceKey, null); + set(metaStore, completeSourceKey, sourceMetadata); + set(siteStructure, nestedSourceKey, completeSourceKey); + // Save the filepath in the data repository for each lookup using the source key + set(filenameLookup, completeSourceKey, filepath); + } + } + } + } } + } catch (error) { + console.error(`Error processing raw data - ${error}`); } - // Disabled the no direct mutation rule here as this only gets called from the constructor - /* eslint-disable react/no-direct-mutation-state */ - this.state.siteInfo = siteData; - /* eslint-enable react/no-direct-mutation-state */ + // Here we add the data directly as this is on first load + // We retrieve the data for the default source + // TODO - can we show errors to the user in a clean way instead of just console? + let filepath = null; + try { + filepath = get(filenameLookup, defaultSourceKey); + } catch (error) { + console.error(`Error retrieving filename - ${error}`); + return; + } + + const url = new URL(filepath, this.dataRepoURL).href; + console.log("Retrieving default source data from ", url); + retrieveJSON(url) + .then((result) => { + const forPlotly = this.toPlotly(result); + set(dataStore, defaultSourceKey, forPlotly); + }) + .then(() => { + this.setState({ isLoaded: true }); + }); + + this.setState({ + dataStore: dataStore, + metaStore: metaStore, + siteStructure: siteStructure, + filenameLookup: filenameLookup, + defaultSpecies: defaultSpecies, + selectedSources: new Set([defaultSourceKey]), + selectedSpecies: defaultSpecies, + defaultSourceKey: defaultSourceKey, + }); } + componentDidMount() { + // Retrieve the metadata + const metadata_filename = "metadata_complete.json"; + const metadataURL = new URL(metadata_filename, this.dataRepoURL); + + retrieveJSON(metadataURL).then( + (metadata) => { + this.populateAndRetrieve(metadata); + }, + (error) => { + this.setState({ + isLoaded: true, + error, + }); + } + ); + } + + // These handle app control by the components + + /** + * Selects a data source using a selected sourceKey + * + * @param {string} selection - Key for a data source + */ sourceSelector(selection) { + console.log(selection); let selectedSourcesSet = new Set(); if (selection instanceof Set) { @@ -87,21 +265,35 @@ class Dashboard extends React.Component { // Here we change all the sites and select all species / sectors at that site let selectedSources = cloneDeep(this.state.selectedSources); - for (const source of selectedSourcesSet) { - if (selectedSources.has(source)) { - selectedSources.delete(source); + for (const sourceKey of selectedSourcesSet) { + if (selectedSources.has(sourceKey)) { + selectedSources.delete(sourceKey); } else { - selectedSources.add(source); + selectedSources.add(sourceKey); } } + // Now let's make sure we have all the data for these new selections + // the source will be the key in the + for (const sourceKey of selectedSources) { + this.retrieveData(sourceKey); + } + this.setState({ selectedSources: selectedSources }); } + /** + * Clear the currently selected data sources + */ clearSources() { this.setState({ selectedSources: new Set() }); } + /** + * Selects a species + * + * @param {string} species - Species name + */ speciesSelector(species) { const speciesLower = species.toLowerCase(); @@ -114,9 +306,14 @@ class Dashboard extends React.Component { this.setState({ selectedSpecies: speciesLower }); } + /** + * Selects a species + * + * @param {string} species - Species name + * @param {Set} oldSelectedSources - Set of previously selected sources + */ sourceSpeciesChange(species, oldSelectedSources) { - const processedData = this.state.processedData; - const speciesData = processedData[species]; + const speciesData = this.state.dataStore[species]; let newSources = new Set(); @@ -126,9 +323,13 @@ class Dashboard extends React.Component { } } + // Here we want the default key of another species if (newSources.size === 0) { const defaultSource = Object.keys(speciesData)[0]; - newSources.add(defaultSource); + // This just gives us a partial key, we need to add in the species to get + // a complete sourceKey + const sourceKey = `${species}.${defaultSource}`; + newSources.add(sourceKey); } this.sourceSelector(newSources); @@ -142,154 +343,14 @@ class Dashboard extends React.Component { this.setState({ overlayOpen: true, overlay: overlay }); } + /** Toggles the sidebar */ toggleSidebar() { this.setState({ showSidebar: !this.state.showSidebar }); } - processData(rawData) { - // Process the data and create the correct Javascript time objects - // expected by plotly - - // NOTE - I use data source here, please don't confuse with an OpenGHG Datasource, - let dataKeys = {}; - let processedData = {}; - let siteStructure = {}; - - let defaultSpecies = null; - let defaultSourceKey = 'decc_tac_54m_picarro';; - let defaultNetwork = null; - - try { - for (const [species, networkData] of Object.entries(rawData)) { - if (!defaultSpecies) { - defaultSpecies = species; - } - for (const [network, siteData] of Object.entries(networkData)) { - if (!defaultNetwork) { - defaultNetwork = network; - } - for (const [site, inletData] of Object.entries(siteData)) { - for (const [inlet, instrumentData] of Object.entries(inletData)) { - for (const [instrument, measurementData] of Object.entries(instrumentData)) { - // Data key - const sourceKey = `${network}_${site}_${inlet}_${instrument}`; - const nestedPath = `${species}.${network}.${site}.${inlet}.${instrument}`; - - if (!defaultSourceKey) { - defaultSourceKey = sourceKey; - } - - // We create a nested object for easy automated creation of the interface - set(siteStructure, nestedPath, sourceKey); - - // Create the data structures expected by plotly - const timeseriesData = measurementData["data"]; - const metadata = measurementData["metadata"]; - const rawData = timeseriesData[species]; - - const x_timestamps = Object.keys(rawData); - const x_values = x_timestamps.map((d) => new Date(parseInt(d))); - const y_values = Object.values(rawData); - - const graphData = { - x_values: x_values, - y_values: y_values, - }; - - const dataKey = `${species}.${sourceKey}`; - - - const combinedData = { data: graphData, metadata: metadata}; - - set(processedData, dataKey, combinedData); -; - } - } - } - - } - } - } catch (error) { - console.error(`Error processing raw data - ${error}`); - } - - // Disabled the no direct mutation rule here as this only gets called from the constructor - /* eslint-disable react/no-direct-mutation-state */ - // Give each site a colour - this.state.defaultSpecies = defaultSpecies; - this.state.defaultNetwork = defaultNetwork; - this.state.defaultSourceKey = defaultSourceKey; - this.state.selectedSources = new Set([defaultSourceKey]); - this.state.selectedSpecies = defaultSpecies; - this.state.processedData = processedData; - this.state.selectedKeys = dataKeys; - this.state.isLoaded = true; - this.state.siteStructure = siteStructure; - /* eslint-enable react/no-direct-mutation-state */ - } - - dataSelector(dataKeys) { - this.setState({ selectedKeys: dataKeys }); - } - - componentDidMount() { - this.processData(deccMeasData); - this.setState({ isLoaded: true }); - // const apiURL = "https://raw.githubusercontent.com/openghg/dashboard_data/main/combined_data.json"; - // fetch(apiURL) - // .then((res) => res.json()) - // .then( - // (result) => { - // this.processData(result); - // this.setState({ - // isLoaded: true, - // }); - // }, - // (error) => { - // this.setState({ - // isLoaded: true, - // error, - // }); - // } - // ); - } - - anySelected() { - for (const subdict of Object.values(this.state.selectedKeys)) { - for (const value of Object.values(subdict)) { - if (value === true) { - return true; - } - } - } - - return false; - } - - setSiteOverlay(e) { - const siteCode = String(e.target.dataset.onclickparam).toUpperCase(); - const siteInfo = this.state.siteInfo[siteCode]; - - const siteText = siteInfo["description"]; - const image = siteInfo["image"]; - const alt = `Image of ${siteCode}`; - - const overlay = ( - - ); - - this.toggleOverlay(); - this.setOverlay(overlay); - } - render() { let { error, isLoaded } = this.state; - let overlay = null; - if (this.state.overlayOpen) { - overlay = {this.state.overlay}; - } - let extraSidebarStyle = {}; if (this.state.showSidebar) { extraSidebarStyle = { transform: "translateX(0px)" }; @@ -306,17 +367,16 @@ class Dashboard extends React.Component { } else { const liveData = ( ); @@ -324,7 +384,15 @@ class Dashboard extends React.Component {
- +
☰ @@ -351,10 +419,11 @@ class Dashboard extends React.Component { } /> + - }/> + + } /> - {overlay}
); diff --git a/src/ProtectedRoute/ProtectedRoute.js b/src/ProtectedRoute/ProtectedRoute.js new file mode 100644 index 0000000..0a500db --- /dev/null +++ b/src/ProtectedRoute/ProtectedRoute.js @@ -0,0 +1,20 @@ +import React from "react"; +import { useAuth0 } from "@auth0/auth0-react"; +import { Navigate } from "react-router-dom"; + +const ProtectedRoute = ({ children }) => { + const { isAuthenticated, loginWithRedirect, isLoading } = useAuth0(); + + if (isLoading) { + return
Loading...
; + } + + if (!isAuthenticated) { + loginWithRedirect(); // Redirect to Auth0 login + return null; + } + + return children; +}; + +export default ProtectedRoute; diff --git a/src/components/LeafletMap/LeafletMap.js b/src/components/LeafletMap/LeafletMap.js index 15b3adc..b35346f 100644 --- a/src/components/LeafletMap/LeafletMap.js +++ b/src/components/LeafletMap/LeafletMap.js @@ -1,9 +1,7 @@ import PropTypes from "prop-types"; import React from "react"; import { LayerGroup, MapContainer, ImageOverlay, TileLayer, CircleMarker, Popup } from "react-leaflet"; -// import TextButton from "../TextButton/TextButton"; -// import "./LeafletMapResponsive.css"; - +import { get } from "lodash"; import TextButton from "../TextButton/TextButton"; import styles from "./LeafletMap.module.css"; @@ -21,97 +19,89 @@ class LeafletMap extends React.Component { } createMarkers() { - - const processedData = this.props.processedData; - const siteStructure = this.props.siteStructure; + const dataStore = this.props.dataStore; const selectedSpecies = this.props.selectedSpecies; - let markers = []; - if(siteStructure !== undefined && processedData !== undefined){ - const speciesStructure = siteStructure[selectedSpecies]; - const speciesData = processedData[selectedSpecies]; - - - - // We want a marker for each site, with selection buttons within the popup - for (const siteData of Object.values(speciesStructure)) { - for (const inletData of Object.values(siteData)) { - let marker = null; - let sourceButtons = []; - // The site metadata we require will be the same for each inlet / instrument - let siteMetadata = null; - - const buttonStyling = { fontSize: "0.8em", width:"0.8em" }; - - for (const [inlet, instrumentData] of Object.entries(inletData)) { - for (const sourceKey of Object.values(instrumentData)) { - const button = ( - - {inlet} - - ); + const metaStore = this.props.metaStore; + // The only site structure we want here are sites that have provided data + // for the species we're interested in + const siteStructure = this.props.siteStructure[selectedSpecies]; - sourceButtons.push(button); + // We'll make a marker for each site + let markers = []; - if (!siteMetadata) { - siteMetadata = speciesData[sourceKey]["metadata"]; + // TODO - Are props always defined? Are we making the right comparison here? + if (metaStore !== undefined && dataStore !== undefined) { + // We want a marker for each site, with selection buttons within the popup + for (const siteData of Object.values(siteStructure)) { + for (const inletData of Object.values(siteData)) { + let marker = null; + let sourceButtons = []; + // The site metadata we require will be the same for each inlet / instrument + let siteSpecificMetadata = null; + + const buttonStyling = { fontSize: "0.8em", width: "0.8em" }; + + for (const [inlet, instrumentData] of Object.entries(inletData)) { + for (const sourceKey of Object.values(instrumentData)) { + // TODO - do we want separate buttons for the different instruments? + siteSpecificMetadata = get(metaStore, sourceKey); + const button = ( + + {inlet} + + ); + + sourceButtons.push(button); } - } - } - try { - const station_latitude = siteMetadata["station_latitude"]; - const station_longitude = siteMetadata["station_longitude"]; - - const locationStr = `${station_latitude}, ${station_longitude}`; - const location = [station_latitude, station_longitude]; - const siteName = siteMetadata["station_long_name"]; - - marker = ( - - -
-
-
{siteName.toUpperCase()}
-
- Select inlet: - {sourceButtons}
-
- For more information please visit the  - - the DECC network website. - + try { + const station_latitude = siteSpecificMetadata["station_latitude"]; + const station_longitude = siteSpecificMetadata["station_longitude"]; + + const locationStr = `${station_latitude}, ${station_longitude}`; + const location = [station_latitude, station_longitude]; + const siteName = siteSpecificMetadata["station_long_name"]; + + marker = ( + + +
+
+
{siteName.toUpperCase()}
+
+ Select inlet: + {sourceButtons} +
+
+ For more information please visit the  + + the DECC network website. + +
+
Location: {locationStr}
-
Location: {locationStr}
-
- - - ); - - markers.push(marker); - } catch (error) { - console.log(error); - continue; + + + ); + + markers.push(marker); + } catch (error) { + console.log(error); + continue; + } } } } - - } return markers; } @@ -137,7 +127,7 @@ class LeafletMap extends React.Component { const markers = this.createMarkers(); const zoom = this.props.zoom ? this.props.zoom : 5; - const style = { width: "90%"}; + const style = { width: "90%" }; return (
diff --git a/src/components/LiveData/LiveData.js b/src/components/LiveData/LiveData.js index 0a132a2..7ac2db2 100644 --- a/src/components/LiveData/LiveData.js +++ b/src/components/LiveData/LiveData.js @@ -28,7 +28,9 @@ class LiveData extends React.Component { clearSources={this.props.clearSources} speciesSelector={this.props.speciesSelector} selectedSources={this.props.selectedSources} - processedData={this.props.processedData} + dataStore={this.props.dataStore} + metaStore={this.props.metaStore} + siteStructure={this.props.siteStructure} selectedSpecies={this.props.selectedSpecies} defaultSpecies={this.props.defaultSpecies} /> @@ -42,8 +44,8 @@ class LiveData extends React.Component { selectedSpecies={this.props.selectedSpecies} centre={mapCentre} zoom={5} - processedData={this.props.processedData} - siteInfoOverlay={this.props.setSiteOverlay} + dataStore={this.props.dataStore} + metaStore={this.props.metaStore} siteStructure={this.props.siteStructure} />
@@ -55,7 +57,7 @@ class LiveData extends React.Component { LiveData.propTypes = { clearSources: PropTypes.func.isRequired, defaultSpecies: PropTypes.string.isRequired, - processedData: PropTypes.object.isRequired, + dataStore: PropTypes.object.isRequired, selectedSources: PropTypes.object.isRequired, selectedSpecies: PropTypes.string.isRequired, setSiteOverlay: PropTypes.func.isRequired, diff --git a/src/components/MultiSiteLineChart/MultiSiteLineChart.js b/src/components/MultiSiteLineChart/MultiSiteLineChart.js index b15c2d4..f337ea4 100644 --- a/src/components/MultiSiteLineChart/MultiSiteLineChart.js +++ b/src/components/MultiSiteLineChart/MultiSiteLineChart.js @@ -5,26 +5,27 @@ import { toTitleCase } from "../../util/helpers"; import styles from "./MultiSiteLineChart.module.css"; import jsPDF from "jspdf"; import html2canvas from "html2canvas"; -import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined'; +import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined"; import { Button } from "@mui/material"; -import { createImage } from "../../util/helpers" +import { createImage } from "../../util/helpers"; import colours from "../../data/colours.json"; +import { get } from "lodash"; class MultiSiteLineChart extends React.Component { - /* + /* This method takes care of downloading the Plot on the website in format of PNG It fetches the html tag for plot and converts to PNG */ handleDownloadPNG = (species, sites) => { const chartContainer = document.getElementById("chart-container"); - let filenames = [species, ...sites].join('_'); - + let filenames = [species, ...sites].join("_"); + if (chartContainer) { html2canvas(chartContainer).then((canvas) => { const imgData = canvas.toDataURL("image/png"); - + const link = document.createElement("a"); link.href = imgData; link.download = `${filenames}.png`; @@ -34,8 +35,8 @@ class MultiSiteLineChart extends React.Component { console.error("Chart container not found."); } }; - - /* + + /* This method takes care of downloading the Plot on the website in format of PDF It fetches the html tag for plot and converts to PDF @@ -43,7 +44,7 @@ class MultiSiteLineChart extends React.Component { handleDownloadPDF = (species, sites) => { // Here we fetch the html element of chart-container that needs to be downloaded by id. const chartContainer = document.getElementById("chart-container"); - let filenames = [species, ...sites].join('_'); + let filenames = [species, ...sites].join("_"); if (chartContainer) { html2canvas(chartContainer).then((canvas) => { @@ -51,53 +52,57 @@ class MultiSiteLineChart extends React.Component { const chartWidth = chartContainer.offsetWidth; const chartHeight = chartContainer.offsetHeight; - + const pdf = new jsPDF({ orientation: chartWidth > chartHeight ? "landscape" : "portrait", unit: "mm", format: [chartWidth, chartHeight], }); - + pdf.addImage(imgData, "JPEG", 0, 0, chartWidth, chartHeight); - + pdf.save(filenames); }); } else { console.error("Chart container not found."); } }; + render() { let plotData = []; let maxY = 0; let minY = Infinity; - const data = this.props.data; let species = null; let units = null; + let sites = []; - for (const sourceData of Object.values(data)) { - const metadata = sourceData["metadata"]; - const measurementData = sourceData["data"]; + for (const sourceKey of this.props.selectedSources) { + const metadata = get(this.props.metaStore, sourceKey); - const xValues = measurementData["x_values"]; - const yValues = measurementData["y_values"]; + const timeseriesData = get(this.props.dataStore, sourceKey, null); + // // TODO - how to handle this error cleanly? + if (timeseriesData === null) { + console.log("No timeseries data available for this source.") + continue; + } + + const xValues = timeseriesData["x_values"]; + const yValues = timeseriesData["y_values"]; const max = Math.max(...yValues); const min = Math.min(...yValues); - if (max > maxY) { - maxY = max; - } - - if (min < minY) { - minY = min; - } + if (max > maxY) maxY = max; + if (min < minY) minY = min; // Set the name for the legend let name = null; try { const siteName = metadata["station_long_name"]; const inlet = metadata["inlet"]; + // We'll save these in case we want to write to file + sites.push(metadata["site"]); name = `${toTitleCase(siteName)} - ${inlet}`; } catch (error) { @@ -106,18 +111,16 @@ class MultiSiteLineChart extends React.Component { const colour = colours["pastelColours"]; units = metadata["units"]; - species = metadata["species"] - + species = metadata["species"]; + if (units === undefined) { - if (species === 'ch4' || species === 'co' || species === 'n2o') { - units = 'ppb'; - } else if (species === 'co2') { - units = 'ppm'; - } else { - units = metadata["units"]; + if (species === "ch4" || species === "co" || species === "n2o") { + units = "ppb"; + } else if (species === "co2") { + units = "ppm"; } } - + const trace = { x: xValues, y: yValues, @@ -134,38 +137,19 @@ class MultiSiteLineChart extends React.Component { plotData.push(trace); } - /*fetching all the site names to pass them as filename - using regex to remove and "-" within the name - */ - let sites = []; - sites = plotData.map(item => item.name); - sites = sites.map(item => item.replace(/<\/?b>/g, '').replace(/\s*-\s*/g, '')); - - let dateMarkObject = null; - const selectedDate = this.props.selectedDate; - - if (selectedDate) { - const date = new Date(parseInt(selectedDate)); - - dateMarkObject = { - type: "line", - x0: date, - y0: minY, - x1: date, - y1: maxY, - line: { - color: "black", - width: 1, - }, - }; - } - const widthScaleFactor = 0.925; const uniOfBristol = require(`../../images/UniOfBristolLogo.png`); const metOffice = require(`../../images/Metoffice.png`); const ncas = require(`../../images/ncas.png`); const openghg = require(`../../images/OpenGHG_Logo_Landscape.png`); - + + let yLabel = null; + if (species !== null) { + yLabel = `${species.toUpperCase()} (${units})`; + } else { + console.error(`species is null`); + } + const layout = { title: { text: this.props.title ? this.props.title : null, @@ -188,16 +172,15 @@ class MultiSiteLineChart extends React.Component { linecolor: "black", autotick: true, ticks: "outside", - }, yaxis: { automargin: true, title: { - text: `${species.toUpperCase()} (${units})`, + text: yLabel, standoff: 10, font: { - size:16, - } + size: 16, + }, }, range: this.props.yRange ? this.props.yRange : null, showgrid: false, @@ -221,37 +204,41 @@ class MultiSiteLineChart extends React.Component { t: 20, pad: 5, }, - shapes: [dateMarkObject], }; - return ( -
-
- -
-
- - + + if (plotData.length === 0) { + return
Retrieving data...
; + } else { + return ( +
+
+ +
+
+ + +
-
- ); + ); + } } } diff --git a/src/components/ObsBox/ObsBox.js b/src/components/ObsBox/ObsBox.js index 03e19f8..a191cac 100644 --- a/src/components/ObsBox/ObsBox.js +++ b/src/components/ObsBox/ObsBox.js @@ -2,81 +2,40 @@ import PropTypes from "prop-types"; import React from "react"; import GraphContainer from "../GraphContainer/GraphContainer"; import MultiSiteLineChart from "../MultiSiteLineChart/MultiSiteLineChart"; - -import { isEmpty, getVisID } from "../../util/helpers"; +import SelectOptions from "../SelectOptions/SelectOptions"; +import { getVisID } from "../../util/helpers"; +import { Button } from "@mui/material"; import styles from "./ObsBox.module.css"; -import SelectOptions from "../SelectOptions/SelectOptions" -import { Button } from "@mui/material"; class ObsBox extends React.Component { createEmissionsGraphs() { - const processedData = this.props.processedData; const selectedSources = this.props.selectedSources; - const selectedSpecies = this.props.selectedSpecies; - - const noSiteSelected = selectedSources.size === 0; - if (noSiteSelected) { + if (selectedSources.size === 0) { return
Please select a site
; } - let dataToPlot = {}; - let multiUnits = []; - if (selectedSources) { - const speciesData = processedData[selectedSpecies]; - - for (const key of selectedSources) { - dataToPlot[key] = speciesData[key]; - - try { - const units = speciesData[key]["metadata"]["units"]; - multiUnits.push(units); - } catch (error) { - console.error(`Error reading units - ${error}`); - } - } - - if (!isEmpty(dataToPlot)) { - // Do a quick check to make sure all the units are the same - let units = ""; - if (new Set(multiUnits).size === 1) { - units = ` (${multiUnits[0]})`; - } else { - console.error(`Multiple units for same species - ${multiUnits}`); - } - - const key = Object.keys(dataToPlot).join("-"); - - const widthScale = 0.9; - const heightScale = 0.9; - - // We only set the title of the graph if there's one site selected - let title = null; - const xLabel = "Date"; - const yLabel = `Concentration${units}`; - - const vis = ( - - - - ); - - return vis; - } else { - console.error("No data to plot."); - return null; - } + const key = Object.keys(selectedSources).join("-"); + + const widthScale = 0.9; + const heightScale = 0.9; + + const vis = ( + + + + ); + + return vis; } } @@ -85,21 +44,25 @@ class ObsBox extends React.Component { let clearButton = null; if (siteSelected) { - clearButton = ; + clearButton = ( + + ); } - const availableSpecies = Object.keys(this.props.processedData); + const availableSpecies = Object.keys(this.props.dataStore); return (
{this.createEmissionsGraphs()}
+
{clearButton}
-
); @@ -108,7 +71,7 @@ class ObsBox extends React.Component { ObsBox.propTypes = { clearSources: PropTypes.func.isRequired, - processedData: PropTypes.object.isRequired, + dataStore: PropTypes.object.isRequired, selectedSources: PropTypes.object.isRequired, selectedSpecies: PropTypes.string.isRequired, speciesSelector: PropTypes.func.isRequired, diff --git a/src/data/defaults.json b/src/data/defaults.json new file mode 100644 index 0000000..af3f0cf --- /dev/null +++ b/src/data/defaults.json @@ -0,0 +1,7 @@ +{ + "site": "tac", + "network": "decc", + "species": "co2", + "inlet": "185m", + "instrument": "picarro" +} diff --git a/src/index.js b/src/index.js index cf7ad43..080df01 100644 --- a/src/index.js +++ b/src/index.js @@ -1,13 +1,20 @@ import React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; +import { Auth0Provider } from "@auth0/auth0-react"; import Dashboard from './Dashboard'; import reportWebVitals from './reportWebVitals'; ReactDOM.render( + , + , document.getElementById('root') ); diff --git a/src/util/helpers.js b/src/util/helpers.js index aa23c7f..c735700 100644 --- a/src/util/helpers.js +++ b/src/util/helpers.js @@ -19,16 +19,17 @@ export function isEmpty(obj) { export function createImage(source, x, opacity = 0.6) { return { source: source, - xref: 'paper', - yref: 'paper', + xref: "paper", + yref: "paper", x: x, y: 0.89, sizex: 0.09, sizey: 0.09, opacity: opacity, - xanchor: 'center', - yanchor: 'middle', - }}; + xanchor: "center", + yanchor: "middle", + }; +} // export function importSVGs() { // let footprints = {}; @@ -90,7 +91,6 @@ export function importSiteImages() { } for (const path of paths) { - // Here we need to read the filename and convert it to a UNIX timestamp const filename = String(path).split("./")[1]; const sansExtension = String(filename).split(".")[0].toUpperCase(); @@ -112,3 +112,20 @@ export function toTitleCase(str) { return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); }); } + +/** + * Creates an instrument key to be used in the lookup tables + * for data and filenames + * + * @param {string} species - Species + * @param {string} network- Network code + * @param {string} site - Site code + * @param {string} inlet - Inlet height + * @param {string} instrument - Instrument name + + * @param {string} sourceKey - Source key + * + */ +export function createSourceKey(species, network, site, inlet, instrument) { + return `${species}.${network}_${site}_${inlet}_${instrument}`; +}