pacificclimate/climate-explorer-frontend

View on GitHub
src/components/graphs/DualAnnualCycleGraph.js

Summary

Maintainability
C
1 day
Test Coverage
import React from "react";

import _ from "lodash";

import { timeseriesToAnnualCycleGraph } from "../../core/chart-generators";
import {
  assignColoursByGroup,
  fadeSeriesByRank,
  padYAxis,
  matchYAxisRange,
} from "../../core/chart-formatters";
import {
  hasTwoYAxes,
  yAxisUnits,
  yAxisRange,
} from "../../core/chart-accessors";
import AnnualCycleGraph from "./AnnualCycleGraph";
import { getVariableOptions, findMatchingMetadata } from "../../core/util";

export default function DualAnnualCycleGraph({
  model_id,
  experiment,
  variable_id,
  meta,
  comparand_id,
  comparandMeta,
  area,
}) {
  function getMetadata(dataSpec) {
    // Find and return metadata matching the data specification (`dataSpec`):
    // model_id, experiment, variable_id, start_date, end_date, ensemble_member
    // for monthly, seasonal and annual timescales.
    // Variable, model, and experiment are supplied by the graph's parent, but
    // start, end, and run are selected here.
    // Do the the same for comparand_id and comparandMeta.

    // `dateTolerance` establishes the tolerance (in years) for matches to
    // `start_date` and `end_date`. (`dataSpec` specifies the values to match.)
    const dateTolerance = 1;

    const timescales = ["monthly", "seasonal", "yearly"];

    // Find matching metadata sets for variable (`variable_id`).
    const variableMetadataSets = timescales.map((timescale) =>
      findMatchingMetadata(meta, dateTolerance, {
        model_id,
        experiment,
        variable_id: variable_id,
        timescale,
        ...dataSpec,
      }),
    );

    // Find matching metadata sets for comparand (comparand_id).
    // Only use comparand metadata sets for which there is a corresponding
    // variable metadata set with the same timescale.
    // This is a function because we don't always compute it.
    const comparandMetadataSets = () =>
      timescales.map(
        (timescale, i) =>
          variableMetadataSets[i] &&
          findMatchingMetadata(comparandMeta, dateTolerance, {
            model_id,
            experiment,
            variable_id: comparand_id,
            timescale,
            ...dataSpec,
          }),
      );

    // Extend variable metadata sets with comparand metdata sets
    // if the comparand is different from the variable.
    const allMetadataSets = variableMetadataSets.concat(
      variable_id === comparand_id ? [] : comparandMetadataSets(),
    );

    return _.compact(allMetadataSets);
  }

  function dataToGraphSpec(meta, data) {
    // Convert `data` (described by `meta`) to a graph specification compatible
    // with `DataGraph`.

    let graph = timeseriesToAnnualCycleGraph(meta, ...data);

    // function that assigns each data series to one of two groups based on
    // which variable it represents. Passed to assignColoursByGroup to assign
    // graph line colors.
    const sortByVariable = (dataSeries) => {
      const seriesName = dataSeries[0].toLowerCase();
      if (seriesName.search(variable_id.toLowerCase()) !== -1) {
        return 0;
      } else if (seriesName.search(comparand_id.toLowerCase()) !== -1) {
        return 1;
      } else {
        // if only one variable is selected, it won't be in any series names.
        return seriesName;
      }
    };

    graph = assignColoursByGroup(graph, sortByVariable);

    //function that assigns seasonal and annual timeseries lower "rank"
    //then monthly timeseries. Passed to fadeSeries to make higher-resolution
    //data stand out more.
    const rankByTimeResolution = (dataSeries) => {
      var seriesName = dataSeries[0].toLowerCase();
      if (seriesName.search("monthly") !== -1) {
        return 1;
      } else if (seriesName.search("seasonal") !== -1) {
        return 0.6;
      } else if (seriesName.search("yearly") !== -1) {
        return 0.3;
      }
      //no time resolution indicated in timeseries. default to full rank.
      return 1;
    };

    graph = fadeSeriesByRank(graph, rankByTimeResolution);

    // In a few cases, different variables move in lockstep over the course of
    // the year, e.g tasmin and tasmax: tasmin is almost always about 9
    // degrees lower than tasmax. These pairs of variables will be hard to
    // understand in a comparison graph, because c3's normally excellent
    // smart formatting will automatically scale each data set vertically
    // to take advantage of the entire graph space, making the two sets of
    // graph lines nearly identical.
    //
    // The variable configuration file indicates which pairs of variables are
    // expected to have this issue. If either of the two currently displayed
    // variables lists the other as a visual conflict (using the "shiftAnnualCycle"
    // attribute), they will be detangled by padding the graph y-scales to
    // shift their respective graph lines apart vertically.
    if (hasTwoYAxes(graph) && comparand_id !== variable_id) {
      // see if either variable is listed as conflicting with the other
      const variableOverlaps = getVariableOptions(
        variable_id,
        "shiftAnnualCycle",
      );
      const comparandOverlaps = getVariableOptions(
        comparand_id,
        "shiftAnnualCycle",
      );

      // see if both variables are annual-only, in which case they visually
      // overlap as well, because both will be graphed as horizontal lines.
      const annualOnly =
        _.reject(meta, (m) => m.timescale === "yearly").length === 0;

      const overlap =
        (comparandOverlaps && comparandOverlaps.includes(variable_id)) ||
        (variableOverlaps && variableOverlaps.includes(comparand_id));

      if (overlap || annualOnly) {
        // if the two data series have overlapping ranges and the same units,
        // set their y axes to the same range to avoid
        // the misleading visuals of *slightly* different y axes.
        //
        // otherwise, just pad each axis by a flat 20% to move the
        // data sets apart visually.

        // determine whether the data ranges overlap:
        const yRange = yAxisRange(graph, "y");
        const y2Range = yAxisRange(graph, "y2");
        if (
          yAxisUnits(graph, "y") === yAxisUnits(graph, "y2") &&
          !(yRange.max < y2Range.min || y2Range.max < yRange.min)
        ) {
          // y axes will have the same range
          graph = matchYAxisRange(graph);
        } else {
          // y axes padded by 20%
          const shiftUpAxis = yRange.max > y2Range.max ? "y" : "y2";
          const shiftDownAxis = yRange.max < y2Range.max ? "y" : "y2";
          graph = padYAxis(graph, shiftUpAxis, "bottom", 0.2);
          graph = padYAxis(graph, shiftDownAxis, "top", 0.2);
        }
      }
    }
    return graph;
  }

  return (
    <AnnualCycleGraph
      {...{
        model_id,
        experiment,
        variable_id,
        meta,
        comparand_id,
        comparandMeta,
        area,
        getMetadata,
        dataToGraphSpec,
      }}
    />
  );
}