pacificclimate/climate-explorer-frontend

View on GitHub
src/components/map-controllers/DualMapController/DualMapController.js

Summary

Maintainability
D
2 days
Test Coverage
/*************************************************************************
 * DualMapController - displays two variables on the map at once
 * 
 * This controller coordinates a map displaying two layers at once:
 *   * A raster layer with colour shading reflecting values
 *   * A coloured isoline layer
 * 
 * The layers may display separate timestamps at once. The user may 
 * configure map layer settings including colour pallete, period and run,
 * whether colour to value mapping is logarithmic, and selected timestamp
 * on a map settings menu.
 * 
 * Props: one or two arrays of metadata describing available files
 * 
 * Children:
 *   * Datamap, which actually renders the map
 *   * MapSettings, which allows user configuration of map layers
 *************************************************************************/

// Wires up components of overall map display for CE.
// Also contains some legacy code that should be further refactored, primarily
// `loadMap` and the handling of datasets (see TODOs/FIXMEs).

import PropTypes from 'prop-types';
import React from 'react';
import Loader from 'react-loader';
import { Panel, Row, Col } from 'react-bootstrap';

import _ from 'lodash';

import '../MapController.module.css';
import DataMap from '../../DataMap';
import MapLegend from '../../MapLegend';
import MapSettings from '../../MapSettings';
import StaticControl from '../../StaticControl';

import {
  hasValidData,
  currentDataSpec,
  scalarParams,
  getTimeParametersPromise,
  selectRasterPalette,
  selectIsolinePalette,
  updateLayerSimpleState,
  updateLayerTime,
  getDatasetIdentifiers,
} from '../map-helpers.js';

import styles from '../MapController.module.css';
import { mapPanelLabel } from '../../guidance-content/info/InformationItems';
import { MEVSummary } from '../../data-presentation/MEVSummary';


// TODO: https://github.com/pacificclimate/climate-explorer-frontend/issues/125
export default class DualMapController extends React.Component {
  static propTypes = {
    model_id: PropTypes.string.isRequired,
    experiment: PropTypes.string.isRequired,
    variable_id: PropTypes.string.isRequired,
    meta: PropTypes.array.isRequired,
    comparand_id: PropTypes.string,
    comparandMeta: PropTypes.array,
    area: PropTypes.object,
    onSetArea: PropTypes.func.isRequired,
  };

  constructor(props) {
    super(props);

    this.state = {
      run: undefined,
      start_date: undefined,
      end_date: undefined,

      raster: {
        variableId: undefined, // formerly 'variable'
        times: undefined,
        timeIdx: undefined,
        wmsTime: undefined,
        palette: 'seq-Greys',
        logscale: 'false',
        range: {},
      },

      isoline: this.props.comparandMeta ? {
        variableId: undefined, // formerly 'comparand'
        times: undefined,
        timeIdx: undefined,
        wmsTime: undefined,
        numberOfContours: 10,
        palette: 'x-Occam',
        logscale: 'false',
        range: {},
      } : {},
    };
  }

  // Support functions

  timesMatch(vTimes = this.state.raster.times, cTimes = this.state.isoline.times) {
    // Returns true if the timestamps available for the variable
    // and the timestamps available for the comparand match
    return !_.isUndefined(vTimes) &&
      !_.isUndefined(cTimes) &&
      _.isEqual(vTimes, cTimes);
  }

  // Support functions for event/callback handlers

  // TODO: https://github.com/pacificclimate/climate-explorer-frontend/issues/125
  loadMap(
    props,
    dataSpec,
    newVariable = false,
    newComparand = false
  ) {
    // update state with all the information needed to display
    // maps for specific dataspecs.
    // A 'dataspec' is a variable + emissions + model + period + run combination. 
    // Timestamps for an dataspec may be spread across up to three files 
    // (one annual, one seasonal, one monthly). DualMapController implicitly receives
    // the variable, emissions scenario, and model parameters via its metadata props,
    // which have been filtered on those parameters. It selects a period and run and 
    // stores them in state, but doesn't select (or store in state) a specific file with a
    // specific unique_id until rendering, when it needs to pass an exact file
    // and timestamp to the viewer component DataMap.
    // The variable and the comparand may have different available timestamps, but will
    // (aside from variable_id) display the same dataspec.

    const { start_date, end_date, ensemble_member } = dataSpec;
    
    const rasterScalarParams = scalarParams.bind(null, props.variable_id);
    const rasterParamsPromise = getTimeParametersPromise(dataSpec, props.meta)
      .then(rasterScalarParams)
      .then(selectRasterPalette);
    
    const isolineScalarParams = scalarParams.bind(null, props.comparand_id);
    const isolineParamsPromise = getTimeParametersPromise(dataSpec, props.comparandMeta)
      .then(isolineScalarParams)
      .then(selectIsolinePalette);
    
    Promise.all([rasterParamsPromise, isolineParamsPromise]).then(params => {
            
      let rasterParams = _.find(params, {variableId: props.variable_id});
      let isolineParams = _.find(params, {variableId: props.comparand_id});
      
      if(rasterParams === isolineParams) {
        // needed when comparand and variable are the same
        isolineParams = _.clone(rasterParams);
      }
      
      // if the variable or comparand has changed, use the default values, otherwise
      // the ones previously set by the user
      if(!newVariable) {
        rasterParams.palette = this.state.raster.palette;
        rasterParams.logscale = this.state.raster.logscale;
      }
      if(!newComparand) {
        isolineParams.palette = this.state.isoline.palette;
        isolineParams.logscale = this.state.isoline.logscale;
      }
      
      if(isolineParams.palette === 'x-Occam' && 
          rasterParams.palette === 'x-Occam') {
        rasterParams.palette = 'seq-Greys';
      }
      
      this.setState(prevState => ({
        run: ensemble_member,
        start_date,
        end_date,
        raster: rasterParams,
        isoline: isolineParams
      }));
    });  
  }

  // Handlers for dataSpec change

  // TODO: https://github.com/pacificclimate/climate-explorer-frontend/issues/118
  updateDataSpec = (encodedDataSpec) => {
    this.loadMap(this.props, JSON.parse(encodedDataSpec));
  };
  
  // Handlers for time selection change

  handleChangeVariableTime = updateLayerTime.bind(this, 'raster');
  handleChangeComparandTime = updateLayerTime.bind(this, 'isoline');

  // Handlers for palette change

  handleChangeRasterPalette = updateLayerSimpleState.bind(this, 'raster', 'palette');
  handleChangeIsolinePalette = updateLayerSimpleState.bind(this, 'isoline', 'palette');

  // Handlers for layer range change

  handleChangeRasterRange = updateLayerSimpleState.bind(this, 'raster', 'range');
  handleChangeIsolineRange = updateLayerSimpleState.bind(this, 'isoline', 'range');

  // Handlers for scale change

  // TODO: Naming and values inherited from original code are inconsistent;
  // "scale" and "logscale" are actually synonyms right now for a boolean
  // (represented by a string, argh), but "scale" logically could refer to a
  // value selected from a list of values (which is currently limited to
  // "linear", "logscale", hence the boolean). Fix this.
  handleChangeRasterScale = updateLayerSimpleState.bind(this, 'raster', 'logscale');
  handleChangeIsolineScale = updateLayerSimpleState.bind(this, 'isoline', 'logscale');

  // React lifecycle event handlers

  componentWillReceiveProps(nextProps) {
    // TODO: This stuff, particularly loadMap, may be better placed in
    // componentWillUpdate.

    // Load initial map, based on a list of available data files passed
    // as props from its parent
    // the first dataset representing a 0th time index (January, Winter, or Annual)
    // will be displayed.

    if (hasValidData('variable', nextProps)) {
      const defaultDataset = nextProps.meta[0];
      const defaultDataSpec = _.pick(defaultDataset, 'start_date', 'end_date', 'ensemble_member');

      // check to see whether the variables displayed have been switched.
      // if so, logarithmic display and palettes will be reset to defaults
      const hasComparand = hasValidData('comparand', nextProps);
      const switchVariable = !_.isEqual(this.props.variable_id, nextProps.variable_id);
      const switchComparand = hasComparand && !_.isEqual(this.props.comparand_id, nextProps.comparand_id);

      this.loadMap(nextProps, defaultDataSpec, switchVariable, switchComparand);
    } else {
      // haven't received any displayable data. Probably means user has selected
      // parameters for a dataset that isn't in the database.
      // Clear the map to prevent the previously-generated map causing confusion
      // if the user doesn't notice the footer.
      this.setState({
        variableTimes: undefined,
        variableTimeIdx: undefined,
        comparandTimes: undefined,
        comparandTimeIdx: undefined
      });
    }
  }

  shouldComponentUpdate(nextProps, nextState) {
    // This guards against re-rendering before we have required data
    // TODO: Make more efficient?
    // Currently doing deep comparison on big objects (meta, comparandMeta).
    // Deep comparison matters on rasterRange, isolineRange, but not on
    // meta, comparandMeta, which are likely new objects every time (response
    // from HTTP requests). That could be a lot faster.
    const propChange = !_.isEqual(nextProps, this.props);
    const stateChange = !_.isEqual(nextState, this.state);
    const b = propChange || stateChange;
    return b;
  }

  render() {
    const mapLegend = (<MapLegend
      {...this.props}
      {...this.state}
      hasValidComparand={hasValidData('comparand', this.props)}
    />);

    return (
      <Panel>
        <Panel.Heading>
          <Panel.Title>
            <Row>
              <Col lg={2}>
                {mapPanelLabel}
              </Col>
              <Col lg={10}>
                {mapLegend}
              </Col>
            </Row>

          </Panel.Title>
        </Panel.Heading>
        <Panel.Body className={styles.mapcontroller}>
          {
            this.state.raster.times || this.state.isoline.times ? (
              <DataMap
                raster={{
                  ...getDatasetIdentifiers(
                    this.props, this.state,
                    'variable', this.props.meta, this.state.raster.timeIdx
                  ),
                  ...this.state.raster,
                  defaultOpacity: 0.7,
                  onChangeRange: this.handleChangeRasterRange,
                }}

                isoline={{
                  ...getDatasetIdentifiers(
                    this.props, this.state,
                    'comparand', this.props.comparandMeta, this.state.isoline.timeIdx
                  ),
                  ...this.state.isoline,
                  defaultOpacity: 1.0,
                  onChangeRange: this.handleChangeIsolineRange,
                }}

                onSetArea={this.props.onSetArea}
                area={this.props.area}
                pointSelect={false}
              >

                <StaticControl position='topright'>
                  <MapSettings
                    title='Map Settings'
                    meta={this.props.meta}
                    comparandMeta={this.props.comparandMeta}

                    dataSpec={currentDataSpec(this.state)}
                    onDataSpecChange={this.updateDataSpec}

                    raster={{
                      ...this.state.raster,
                      onChangeTime: this.handleChangeVariableTime,
                      onChangePalette: this.handleChangeRasterPalette,
                      onChangeScale: this.handleChangeRasterScale,
                    }}

                    hasComparand={hasValidData('comparand', this.props)}
                    timesLinkable={this.timesMatch()}
                    isoline={{
                      ...this.state.isoline,
                      onChangeTime: this.handleChangeComparandTime,
                      onChangePalette: this.handleChangeIsolinePalette,
                      onChangeScale: this.handleChangeIsolineScale,
                    }}
                  />
                </StaticControl>

                <StaticControl position='bottomleft'>
                  {mapLegend}
                </StaticControl>

              </DataMap>
            ) : (
              <Loader/>
            )
          }
        </Panel.Body>
      </Panel>
    );
  }
}