src/components/graphs/AnnualCycleGraph/AnnualCycleGraph.js
import PropTypes from "prop-types";
import React from "react";
import { Row, Col, ControlLabel } from "react-bootstrap";
import _ from "lodash";
import { DataspecSelector } from "pcic-react-components";
import DataGraph from "../DataGraph/DataGraph";
import ExportButtons from "../ExportButtons";
import { exportDataToWorksheet } from "../../../core/export";
import { getTimeseries } from "../../../data-services/ce-backend";
import { validateAnnualCycleData } from "../../../core/util";
import {
noDataMessageGraphSpec,
errorMessage,
loadingDataGraphSpec,
} from "../graph-helpers";
import { datasetSelectorLabel } from "../../guidance-content/info/InformationItems";
import { representativeValue } from "../../../core/selectors";
import { setNamedState } from "../../../core/react-component-utils";
import styles from "./AnnualCycleGraph.module.css";
// This component renders an annual cycle graph, including a selector
// for the specific set of data to display and export-data buttons. An annual
// cycle graph presents spatially averaged values of a multi-year mean dataset
// as points over a nominal year (representing the "average" year).
//
// The component is generalized by two function props, `getMetadata`
// and `dataToGraphSpec`, which respectively return metadata describing the
// the datasets to display, and return a graph spec for the graph proper.
export default class AnnualCycleGraph extends React.Component {
// TODO: model_id, variable_id, and experiment are used only to set the
// initial data specification. Could instead make `initialDataSpec` a prop, which
// the client computes according to their own recipe. Not sure whether
// this is a gain or not, since the same computation (`initialDataSpec`)
// would be done in each client.
static propTypes = {
meta: PropTypes.array,
model_id: PropTypes.string,
variable_id: PropTypes.string,
experiment: PropTypes.string,
area: PropTypes.string,
getMetadata: PropTypes.func,
// `getMetadata` returns the metadata describing the datasets to
// be displayed in this component.
// A different function is passed by different clients to specialize
// this general component to particular cases (single vs. dual controller).
dataToGraphSpec: PropTypes.func,
// `dataToGraphSpec` converts data (monthly, seasonal, annual cycle data)
// to a graph spec.
// A different function is passed by different clients to specialize
// this general component to particular cases (single vs. dual controller).
};
// Lifecycle hooks
// Follows React 16+ lifecycle API and recommendations.
// See https://reactjs.org/blog/2018/03/29/react-v-16-3.html
// See https://reactjs.org/blog/2018/03/27/update-on-async-rendering.html
// See https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html
static instance = 0; // for debugging
constructor(props) {
// Multiple instances of this component are created by SingleDataController.
// The instance and state variables `instance` are used to identify the
// instance in debug logging, etc. I'm keeping this because there is still
// some sleuthing to do that can use it.
// (https://github.com/pacificclimate/climate-explorer-frontend/issues/258)
super(props);
this.instance = AnnualCycleGraph.instance++; // for debugging
// See ../README for an explanation of the content and usage
// of state values. This is important for understanding how this
// component works.
this.state = {
instance: this.instance, // for debugging
prevMeta: null,
prevArea: null,
prevDataSpec: null,
dataSpec: undefined,
fetchingData: false,
data: null,
dataError: null,
};
}
static getDerivedStateFromProps(props, state) {
// This function is called whenever a component may be updated,
// due either to props change or to *state change*.
// Props change.
// Assumes that metadata changes when model, variable, or experiment does.
if (props.meta !== state.prevMeta || props.area !== state.prevArea) {
return {
prevMeta: props.meta,
prevArea: props.area,
fetchingData: false, // not quite yet
data: null, // Signal that data fetch is required
dataError: null,
};
}
// State change (dataSpec). Signal need for data fetch.
if (state.prevDataSpec !== state.dataSpec) {
return {
prevDataSpec: state.dataSpec,
fetchingData: false, // not quite yet
data: null, // Signal that data fetch is required
dataError: null,
};
}
// No state update necessary.
return null;
}
componentDidMount() {
this.fetchData();
}
componentDidUpdate(prevProps, prevState) {
if (!this.state.fetchingData && this.state.data === null) {
this.fetchData();
}
}
// Data fetching
representativeValue = (...args) => representativeValue(...args)(this.state);
getAndValidateData = (metadata) =>
getTimeseries(metadata, this.props.area)
.then(validateAnnualCycleData)
.then((response) => response.data);
getMetadatas = () =>
// This fn is called multiple times, so memoize it if inefficient
this.props
.getMetadata(this.representativeValue("dataSpec"))
.filter((metadata) => !!metadata);
fetchData() {
this.setState({ fetchingData: true });
Promise.all(
this.getMetadatas().map((metadata) => this.getAndValidateData(metadata)),
)
.then((data) => {
this.setState({
fetchingData: false,
data,
dataError: null,
});
})
.catch((dataError) => {
this.setState({
// Do we have to set data non-null here to prevent infinite update loop?
fetchingData: false,
dataError,
});
});
}
// User event handlers
handleChangeDataSpec = setNamedState(this, "dataSpec");
exportData(format) {
exportDataToWorksheet(
"timeseries",
_.pick(this.props, "model_id", "variable_id", "experiment", "meta"),
this.graphSpec(),
format,
this.representativeValue("dataSpec"),
);
}
handleExportXlsx = this.exportData.bind(this, "xlsx");
handleExportCsv = this.exportData.bind(this, "csv");
// render helpers
graphSpec() {
// Return a graphSpec appropriate to the given state
// An error occurred
if (this.state.dataError) {
return noDataMessageGraphSpec(errorMessage(this.state.dataError));
}
// Waiting for data
if (this.state.fetchingData || this.state.data === null) {
return loadingDataGraphSpec;
}
// We can haz data
try {
return this.props.dataToGraphSpec(this.getMetadatas(), this.state.data);
} catch (error) {
// dataToGraphSpec may blow a raspberry if the data it is passed is
// invalid. This won't happen due to mismatched dataSpec and data,
// because we don't allow that mismatch to occur.
return noDataMessageGraphSpec(errorMessage(error));
}
}
render() {
return (
<React.Fragment>
<Row>
<Col lg={6} md={6} sm={6}>
<ControlLabel className={styles.selector_label}>
{datasetSelectorLabel}
</ControlLabel>
<DataspecSelector
bases={this.props.meta}
value={this.state.dataSpec}
onChange={this.handleChangeDataSpec}
className={styles.selector}
/>
</Col>
<Col lg={6} md={6} sm={6}>
<ExportButtons
onExportXlsx={this.handleExportXlsx}
onExportCsv={this.handleExportCsv}
/>
</Col>
</Row>
<Row>
<Col>
<DataGraph {...this.graphSpec()} />
</Col>
</Row>
</React.Fragment>
);
}
}