pacificclimate/climate-explorer-frontend

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

Summary

Maintainability
C
1 day
Test Coverage
/*******************************************************************
 * SingleMapController.js - Raster display of map data
 * 
 * This controller coordinates a map displaying data extracted from
 * netCDF files as a colour-coded raster, as well as a menu of 
 * viewing settings for the raster.
 *
 * It allows the user to select an area of interest by interacting
 * with the map. This area can either be a polygon or a point, which is
 * controlled by the pointSelect prop. If the selection area is a point,
 * the watershed of which that point is the mouth will be displayed on
 * the map if the watershedEnsemble prop has a non-null value.
 * 
 * It is also responsible for passing user-drawn areas up to its 
 * parent.
 * 
 * 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 dataspecs (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,
  selectRasterPalette,
  currentDataSpec,
  updateLayerSimpleState,
  updateLayerTime,
  getTimeParametersPromise,
  scalarParams,
  getDatasetIdentifiers,
} from '../map-helpers.js';

import styles from '../MapController.module.css';
import { 
  mapPanelLabel,
  floodMapPanelLabel
} from '../../guidance-content/info/InformationItems';

import { MEVSummary } from '../../data-presentation/MEVSummary';


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

  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: 'x-Occam',
        logscale: 'false',
        range: {},
      },
    };
  }

  // Support functions for event/callback handlers

  // TODO: https://github.com/pacificclimate/climate-explorer-frontend/issues/125
  loadMap(
    props,
    dataSpec,
    newVariable = false,
  ) {
    // update state with all the information needed to display
    // maps for a specific dataSpec.
    // a 'dataspec' in this case is a variable + emissions + model + 
    // period + run combination, which unique describes a set of data that may
    // spread across up to three files (one annual, one seasonal, one monthly). 
    // SingleMapController receives the variable, emissions, and model parameters
    // from its parent as props. The period and run are selected by this component
    // (by default) or adjusted by the user, and stored in state.
    // An exact file for ncWMS to read is not selected until render time, when it 
    // is passed to the viewer component.

    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);
    
    rasterParamsPromise.then(params => {
      //if the variable has changed, use the default palette and logscale,
      //otherwise use the previous (user-selected) values from state.
      if(!newVariable) {
        params.palette = this.state.raster.palette;
        params.logscale = this.state.raster.logscale;
      }
      
      this.setState(prevState => ({
        run: ensemble_member,
        start_date,
        end_date,
        raster: params
      }));
    });  
  }

  // Handlers for dataset 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');

  // Handlers for palette change

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

  // Handlers for layer range change

  handleChangeRasterRange = updateLayerSimpleState.bind(this, 'raster', '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');

  // 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, loadMap will reset palette and logarithmic dispay.
      const switchVariable = !_.isEqual(this.props.variable_id, nextProps.variable_id);


      this.loadMap(nextProps, defaultDataSpec, switchVariable);
    } 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,
      });
    }
  }

  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).
    // Deep comparison matters on rasterRange, but not on
    // meta, which is likely a new object 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() {
    // TODO: Improve returned item
    if (!_.allDefined(this.props, 'model_id', 'experiment', 'variable_id')) {
      return 'Readying...';
    }
    const mapLegend = (<MapLegend
      {...this.props}
      {...this.state}
      hasValidComparand={false}
    />);

    return (
      <Panel>
        <Panel.Heading>
          <Panel.Title>
            <Row>
              <Col lg={2}>
                {this.props.pointSelect ? floodMapPanelLabel : mapPanelLabel}
              </Col>
              <Col lg={10}>
                {mapLegend}
              </Col>
            </Row>
        </Panel.Title>
        </Panel.Heading>
        <Panel.Body className={styles.mapcontroller}>
          {
            this.state.raster.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,
                }}

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

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

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

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

                    hasComparand={false}
                    timesLinkable={false}
                  />
                </StaticControl>

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

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