pacificclimate/climate-explorer-frontend

View on GitHub
src/core/chart-generators.js

Summary

Maintainability
F
4 days
Test Coverage
/* **********************************************************************
 * chart-generators.js - functions that generate a C3 chart specification
 * object from backend query results and metadata describing the query.
 *
 * The three primary functions in this file are:
 * - timeseriesToAnnualCycleGraph, which accepts data from the "timeseries"
 *   API call and generates a structure annual cycle graph with months
 *   labeled along the x-axis.
 *
 * - timeseriesToTimeseriesGraph, which accepts data from the "timeseries"
 *   API call and generates an unstructured timeseries of arbitrary
 *   resolution using whatever dates are available.
 *
 * - dataToLongTermAverageGraph, which accepts data from the "data" API call
 *   and creates timeseries graphs of arbitrary resolution
 *
 * This file also contains helper functions used by the primary functions
 * to generate pieces of the C3 graph-describing data structure, which is
 * specified here: http://c3js.org/reference.html.
 *
 ***************************************************************************/

import _ from "lodash";
import {
  capitalizeWords,
  caseInsensitiveStringSearch,
  dateToPeriod,
  getDataUnits,
  getVariableOptions,
  nestedAttributeIsDefined,
  PRECISION,
  timeResolutionIndexToTimeOfYear,
  timestampToTimeOfYear,
} from "./util";

/* **************************************************
 * 0. Helper functions used by all graph generators *
 ****************************************************/

/*
 * Simple formatting function for numbers to be displayed on the graph.
 * Used as a default when a more specialized formatting function isn't
 * available; ignores all its inputs except the number to be formatted.
 */
function fixedPrecision(n) {
  return +n.toFixed(PRECISION);
}

// Generates a typical y-axis configuration, given the text of the label.
function formatYAxis(label) {
  return {
    label: {
      text: label,
      position: "outer-middle",
    },
    tick: {
      format: fixedPrecision,
    },
    show: true,
  };
}

/*
 * Accepts a object with seriesname:variable pairs.
 * Returns a function that accepts a number and a series name, and formats
 * the number according to precision set in the variable-options.yaml config
 * file for the associated variable, or a default precision with
 * util.PRECISION for variables with no precision options in the file.
 */
function makePrecisionBySeries(series) {
  let dictionary = {};
  for (let s in series) {
    const fromConfig = getVariableOptions(series[s], "decimalPrecision");
    dictionary[s] = _.isUndefined(fromConfig) ? PRECISION : fromConfig;
  }

  return function (n, name) {
    return +n.toFixed(dictionary[name]);
  };
}

/*
 * This formatting function is used in chart tooltips to label the
 * names of data points. It searches the name of the displayed time
 * series for the keywords "Seasonal" or "Monthly". If it finds them,
 * it replaces the keyword with the name of the individual month or
 * season this data point represents ("June", "Winter", etc)
 *
 * If it finds neither keyword, it returns the name of the series
 * unchanged.
 *
 * C3 passes tooltip text formatting functions four arguments:
 *   - the name of the data series (index)
 *   - ratio of this data point to total - pie charts only (ratio)
 *   - nominal value of this data point (value)
 *   - n where this data point is the nth in its series (index)
 * This function only uses the name and index arguments.
 */
function tooltipAddTimeOfYear(name, ratio, value, index) {
  if (caseInsensitiveStringSearch(name, "monthly")) {
    return name.replace(
      /monthly/gi,
      timeResolutionIndexToTimeOfYear("monthly", index),
    );
  } else if (caseInsensitiveStringSearch(name, "seasonal")) {
    // timestamp representing this month - only the month is relevant
    const timestamp = new Date(0, index);
    return name.replace(
      /seasonal/gi,
      timestampToTimeOfYear(timestamp.toISOString(), "seasonal", false),
    );
  } else {
    return name;
  }
}

/*
 * This function returns a number-formatting function for use by the C3
 * tooltip.
 * C3 passes the tooltip formatting function four pieces of information about the
 * datum being examined: data value, ratio (pie charts only), series id,
 * and point index within the series.
 *
 * This function extracts unit names for each data series from the axis
 * labels, then returns a function that uses the series id passed by
 * C3 to append a units string to each value.
 *
 * It optionally accepts a precisionFunction for more exact formatting of
 * numbers. precisionFunction will be passed the number to format and the
 * series id it belongs to.
 */
function makeTooltipDisplayNumbersWithUnits(axes, axis, precisionFunction) {
  let unitsDictionary = {};
  const pf = _.isUndefined(precisionFunction)
    ? fixedPrecision
    : precisionFunction;

  // build a dictionary between timeseries names and units
  for (let series in axes) {
    if (axis[axes[series]].units) {
      // use explicit units if present
      unitsDictionary[series] = axis[axes[series]].units;
    } else {
      // fall back to axis text label
      unitsDictionary[series] = axis[axes[series]].label.text;
    }
  }

  return function (value, ratio, id) {
    return `${pf(value, id)} ${unitsDictionary[id]}`;
  };
}

/*
 * Helper function for assignDataToYAxis.
 *
 * Accepts a c3 chart spec and the name of a y axis (typically "y" or "y2").
 *
 * It determines whether the y axis is already defined in the chart spec.
 *
 * If the axis is defined, it checks for the following parameters
 * of the axis:
 *   - units
 *   - groupBy.type
 *   - groupBy.value
 * These attributes are not part of the standard c3 graph spec; they are added
 * by assignDataToYAxis during chart creation to facilitate adding additional
 * datasets to a pre-existing graph.
 *
 * If the y axis is completely unspecified, returns an object containing empty
 * strings for each sorting parameter. A new unformatted graph, ready for data
 * assignment.
 *
 * If the y axis is defined but does not specify all the sorting attributes,
 * throws an error. It isn't possible to add more data to this graph; not
 * enough information.
 *
 * If the axis is defined and specifies all the sorting parameters, return an object
 * containing them, which assignDataToYAxis will use to assign new data to
 * the graph following the pattern laid out by data already present.
 */
function axisSortingParams(graph, yAxis) {
  let params = {
    units: "",
    groupBy: { type: "", value: "" },
  };
  if (graph.axis) {
    const axis = graph.axis[yAxis];
    if (axis) {
      if (
        nestedAttributeIsDefined(axis, "units") &&
        nestedAttributeIsDefined(axis, "groupBy", "type") &&
        nestedAttributeIsDefined(axis, "groupBy", "value")
      ) {
        params.units = axis.units;
        params.groupBy.type = axis.groupBy.type;
        params.groupBy.value = axis.groupBy.value;
      } else {
        throw new Error("Error: unable to add data to y axis " + yAxis);
      }
    }
  }
  return params;
}

/*
 * This helper function accepts a graph spec object already populated
 * with data series and a metadata object containing variable and
 * unit attributes about each data series, like this:
 *
 * {
 *   "Monthly Tasmax": {
 *     variable: "tasmax",
 *     units: "degC",
 *   },
 *   "Annual Tasmin": {
 *     variable: "tasmin",
 *     units: "degC",
 *   },
 * }
 *
 * If no y-axis is defined, it will create and format up to two as
 * needed (c3 supports >2, but we limit to 2 for readability). If one
 * or more axes is defined, it will assign data to pre-existing axes.
 * It returns the resulting graph spec object.
 *
 * Series can be assigned to axes based on either unit or variable.
 * The default is grouping by variable; calling it with groupByUnits =
 * true will group by units instead. For example, tasmax and tasmin
 * will be graphed with the same y axis if grouped by units (degC) but
 * graphed on separate axes if grouped by variable (tasmin vs tasmax).
 *
 * In addition to standard c3 axis formatting, this function creates
 * and uses the custom "groupBy" and "units" attributes of axis.y and
 * axis.y2. These support adding additional data series to
 * previously-created y-axes.
 *
 * axis.[y|y2].units is a string giving the units.
 * axis.[y|y2].groupBy looks like this:
 * {
 *   type: "variable" | "units",
 *   value: "degC" | "tasmin" | "tasmax" | "days" | etc.
 * }
 *
 * It returns a graph object with formatted axes.
 */
function assignDataToYAxis(graph, seriesMetadata, groupByUnits = false) {
  // get sorting algorithm, if the chart already has one.
  const sortingParams = {
    y: axisSortingParams(graph, "y"),
    y2: axisSortingParams(graph, "y2"),
  };

  if (sortingParams.y.groupBy.type !== sortingParams.y2.groupBy.type) {
    // make sure both axes agree on the data sorting.
    throw new Error("Error: axis data grouping inconsistent.");
  }

  let groupBy = "variable"; // default and most common case.

  if (groupByUnits) {
    // check to make sure the caller of this function didn't request grouping
    // by units when the existing graph is already grouped by variable.
    if (sortingParams.y.groupBy.type === "variable") {
      throw new Error("Error: cannot regroup data series");
    }
    groupBy = "units";
  } else if (sortingParams.y.groupBy.type === "units") {
    // caller didn't specify a grouping algorithm, but
    // this graph is already grouped by units, use that.
    groupBy = "units";
  }

  // collect all data series name, but exclude the data series named "x" - it is
  // a C3 convention used to format labels for the x axis (months & seasons,
  // in our case), not a real data series.
  const seriesNames = graph.data.columns
    .map((col) => col[0])
    .filter((n) => n !== "x");

  const allotSeriesToAxis = (axis, seriesName) => {
    // attempts to sort a data series to the named y axis
    // it checks to see if metadata about the series matches the
    // sorting parameters set for this axis.
    // if no sorting parameters for this axis are set yet, they
    // will be initialized to match this data series.
    // it returns false if the data series cannot be assigned to the axis.
    const seriesMeta = seriesMetadata[seriesName];
    if (!sortingParams[axis].groupBy.value) {
      // this axis has not been initialized. initialize it before assigning.
      sortingParams[axis].groupBy.type = groupBy;
      sortingParams[axis].groupBy.value = seriesMeta[groupBy];
      sortingParams[axis].units = seriesMeta.units;
    }
    // check to see if this data series matches this axis' parameters
    if (seriesMeta[groupBy] === sortingParams[axis].groupBy.value) {
      // it does!
      if (seriesMeta.units !== sortingParams[axis].units) {
        // ... but the units are wrong. Error.
        throw new Error("Error: mismatched units for graph axis " + axis);
      }
      // a valid assignment.
      graph.data.axes[seriesName] = axis;
      return true;
    }
    // not a match
    return false;
  };

  for (const seriesName of seriesNames) {
    if (
      !(
        allotSeriesToAxis("y", seriesName) ||
        allotSeriesToAxis("y2", seriesName)
      )
    ) {
      // this data series is a match for neither Y axis.
      throw new Error("Error: too many data axes required for graph");
    }
  }

  // all data series have been assigned. Format each axis to display accordingly.
  graph.axis = graph.axis ? graph.axis : {};

  const formatSortedAxis = (yAxis) => {
    if (sortingParams[yAxis].groupBy.value && !graph.axis[yAxis]) {
      // data was assigned to this axis, and the axis is not formatted already
      const params = sortingParams[yAxis];
      let label = "";
      if (
        groupBy === "variable" &&
        params.groupBy.value !== missingVariableName
      ) {
        label = `${params.groupBy.value} ${params.units}`;
      } else {
        label = params.units;
      }

      // add the parameters used in sorting the data to the axis spec -
      // so more data can be added later in a subsequent call to assignDataToYAxis
      graph.axis[yAxis] = formatYAxis(label);
      graph.axis[yAxis].units = params.units;
      graph.axis[yAxis].groupBy = params.groupBy;
    }
  };

  formatSortedAxis("y");
  formatSortedAxis("y2");
  return graph;
}

/* ************************************************************
 * 1. timeseriesToAnnualCycleGraph() and its helper functions *
 **************************************************************/

/*
 * Helper constant for timeseriesToAnnualCycleGraph: an X-axis configuration
 * object representing a categorical axis labeled in months.
 */
const monthlyXAxis = {
  type: "category",
  categories: [
    "January",
    "February",
    "March",
    "April",
    "May",
    "June",
    "July",
    "August",
    "September",
    "October",
    "November",
    "December",
  ],
};

/*
 * Helper function for timeseriesToAnnualCycleGraph.
 * Accepts a dataseries object with 1, 4, or 12 timestamp:value pairs
 * and returns an array with twelve values in order by timestamp,
 * repeating values as necessary to get a monthly-resolution sequence.
 */
function getMonthlyData(data, timescale = "monthly") {
  const expectedTimestamps = { monthly: 12, seasonal: 4, yearly: 1 };
  let monthlyData = [];
  const timestamps = Object.keys(data).sort();

  if (timestamps.length === 17) {
    throw new Error("Error: concatenated 17-point chronology.");
  }

  if (timestamps.length !== expectedTimestamps[timescale]) {
    throw new Error("Error: inconsistent time resolution in data");
  }

  for (let i = 0; i < 12; i++) {
    let mapped = Math.ceil((timestamps.length / 12.0) * (i + 1)) - 1;
    monthlyData.push(data[timestamps[mapped]]);
  }

  // Seasonal timeseries need one month of winter removed from the beginning of the
  // year and added at the end, since winter wraps around the calendar new year.
  if (timescale === "seasonal") {
    monthlyData = monthlyData.slice(1, 12);
    monthlyData.push(data[timestamps[0]]);
  }

  return monthlyData;
}

/*
 * Helper function for timeseriesToAnnualCycleGraph. Given a set of timeserieses
 * to be graphed and metadata about each timeseries, returns a function
 * that generates the shortest name necessary to distinguish a particular
 * timeseries from all others being shown on the same chart.
 *
 * For example, when graphing monthly, seasonal, and yearly means for
 * otherwise identical data files, only "monthly", "seasonal," and "yearly"
 * need to appear in the graph legend. But if graphing multiple variables,
 * the graph legend will need to display variable names as well.
 *
 * Timeseries names include any descriptive  metadata that vary between
 * timeseries and leave out any metadata that doesn't. They end with a
 * basename specific to the variable and set in the variable config file
 * (or "mean" if the config file doesn't specify).
 */
// TODO: special case climatological period to display as (XXXX-XXXX)
// TODO: possibly cue descriptors to appear in a specific order?
// "Tasmin Monthly Mean" sounds better than "Monthly Tasmin Mean".
function shortestUniqueTimeseriesNamingFunction(metadata, data) {
  if (metadata.length === 0) {
    throw new Error("No data to show");
  }

  // only one timeseries being graphed, simple label.
  if (data.length === 1) {
    return function (m) {
      return m.timescale === "yearly"
        ? "Annual Mean"
        : capitalizeWords(`${m.timescale} mean`);
    };
  }

  // Compile a list of attributes that can potentially be used to distinguish
  // datasets from each other. These will be used later in forming the naming
  // function returned (and ultimately used in forming the name of each
  // dataset).
  let variation = [];
  const exemplarMetadata = _.find(metadata, { unique_id: data[0].id });

  for (const datum of data) {
    const comparandMetadata = _.find(metadata, { unique_id: datum.id });

    for (const att of Object.getOwnPropertyNames(comparandMetadata)) {
      if (
        exemplarMetadata[att] !== comparandMetadata[att] &&
        variation.indexOf(att) === -1
      ) {
        variation.push(att);
      }
    }
  }

  // Remove variations that are not useful for identifying datasets.
  // All datasets have unique unique_id and unique filepath
  // (which is also very long).
  // We don't need both variable_id and variable_name.
  variation = _.difference(variation, [
    "unique_id",
    "filepath",
    variation.includes("variable_id") && "variable_name",
  ]);

  if (variation.length === 0) {
    throw new Error("Error: cannot graph identical timeseries");
  }

  // Build a dictionary with the base name for each variable (typically
  // either "Mean" or "Mean Count") from the variable config file.
  // Defaults to "Mean" since all data displayed by this graph is MYMs.
  const variables = _.uniq(_.map(metadata, "variable_id"));
  function getVarBasename(v) {
    const fromConfig = getVariableOptions(v, "seriesLegendString");
    return _.isUndefined(fromConfig) ? "Mean" : fromConfig;
  }
  const basenameByVariable = _.zipObject(
    variables,
    _.map(variables, getVarBasename),
  );

  return function (m) {
    let name = "";
    for (let v of variation) {
      name = name.concat(`${m[v]} `);
    }
    name = name.concat(basenameByVariable[m.variable_id]);
    return capitalizeWords(name.replace("yearly", "annual"));
  };
}

/* timeseriesToAnnualCycleGraph()
 * This function takes one or more JSON objects from the
 * "timeseries" API call with this format:
 *
 * {
 * "id": "tasmax_mClim_BCCAQv2_bcc-csm1-1-m_historical-rcp45_r1i1p1_20700101-20991231_Canada",
 * "units": "degC",
 * "data": {
 *   "2085-01-15T00:00:00Z": -17.498223073165622,
 *   "2085-02-15T00:00:00Z": -15.54878007851129,
 *   "2085-03-15T00:00:00Z": -11.671093808333737,
 *                    ...
 *    }
 * }
 *
 * along with an array of dataset metadata entries that includes each
 * dataset referenced by the "id" field in the API results and return
 * a C3 graph object displaying all the timeseries.
 *
 * It takes an arbitrary number of data objects, but no more than
 * two separate unit types. Allowable data resolutions are monthly(12),
 * seasonal (4), or yearly (1); an error will be thrown
 * if this function is called on data with another time resolution.
 */
function timeseriesToAnnualCycleGraph(metadata, ...data) {
  // blank graph data object to be populated - holds data values
  // and individual-timeseries-level display options.
  let c3Data = {
    columns: [],
    types: {},
    labels: {},
    axes: {},
  };

  let seriesMetadata = {};
  let seriesVariables = {};

  const getTimeseriesName = shortestUniqueTimeseriesNamingFunction(
    metadata,
    data,
  );

  // Add each timeseries to the graph
  for (let timeseries of data) {
    // get metadata for this timeseries
    const timeseriesMetadata = _.find(metadata, function (m) {
      return m.unique_id === timeseries.id;
    });
    const timeseriesName = getTimeseriesName(timeseriesMetadata);

    // add the actual data to the graph
    c3Data.columns.push(
      [timeseriesName].concat(
        getMonthlyData(timeseries.data, timeseriesMetadata.timescale),
      ),
    );

    // monthly data is displayed as a line graph, but yearly and seasonal
    // display as step graphs.
    c3Data.types[timeseriesName] =
      timeseriesMetadata.timescale === "monthly" ? "line" : "step";

    seriesMetadata[timeseriesName] = {
      units: getDataUnits(timeseries, timeseriesMetadata.variable_id),
      variable: timeseriesMetadata.variable_id,
    };
    seriesVariables[timeseriesName] = timeseriesMetadata.variable_id;
  }

  // whole-graph display options: axis formatting and tooltip behaviour
  let c3Axis = {};
  c3Axis.x = monthlyXAxis; // format x axis
  let graph = {
    data: c3Data,
    axis: c3Axis,
  };
  graph = assignDataToYAxis(graph, seriesMetadata); // format y axes

  // create tooltip
  const precision = makePrecisionBySeries(seriesVariables);
  let c3Tooltip = { format: {} };
  c3Tooltip.grouped = "true";
  c3Tooltip.format.value = makeTooltipDisplayNumbersWithUnits(
    graph.data.axes,
    graph.axis,
    precision,
  );
  c3Tooltip.format.name = tooltipAddTimeOfYear;

  graph.tooltip = c3Tooltip;
  return graph;
}

/* **********************************************************
 * 2. dataToLongTermAverageGraph() and its helper functions *
 ************************************************************/

/*
 * Helper constant for dataToLongTermAverageGraph: replaces
 * missing variable name in some formatting functions shared
 * by multiple graph types that require a variable name.
 * Not shown to the user.
 * The 'data' API call, when called for a single run, returns no
 * variable name.
 */
const missingVariableName = "defaultVariable";

/*
 * Helper constant for dataToLongTermAverageGraph: Format object
 * for a timeseries X axis labelled by the decadal period.
 */
const periodXAxis = {
  type: "timeseries",
  tick: {
    format: "%Ys",
  },
};

/*
 * Helper function for dataToLongTermAverageGraph. Extracts the
 * list of all unique timestamps found in the data.
 */
function getAllTimestamps(data) {
  let allTimes = [];

  const addSeries = function (seriesData) {
    for (let timestamp in seriesData) {
      if (
        !_.find(allTimes, function (t) {
          return t === timestamp;
        })
      ) {
        allTimes.push(timestamp);
      }
    }
  };

  for (let i in _.keys(data)) {
    if (!_.isUndefined(data[i].data)) {
      // data is from "timeseries" API
      addSeries(data[i].data);
    } else {
      // data is from "data" API
      for (let run in data[i]) {
        addSeries(data[i][run].data);
      }
    }
  }
  if (allTimes.length === 0) {
    throw new Error("Error: no time stamps in data");
  }
  return allTimes;
}

/*
 * Helper function for dataToLongTermAverageGraph. Examines
 * the query context for multiple API calls to the "data"
 * API and determines which possible query parameters
 * (model, variable, emission, or timescale) vary by query.
 *
 * Returns a function that prefixes the "run" parameter
 * from each API call with the parameters that vary between that
 * specific run's call and other calls being graphed at the same time.
 * Example: "tasmax r1i1p1" vs "pr r1i1p1"
 */
function nameAPICallParametersFunction(contexts) {
  let variation = [];
  const exemplarContext = contexts[0];

  for (let context of contexts) {
    for (let att in context) {
      if (
        exemplarContext[att] !== context[att] &&
        variation.indexOf(att) === -1
      ) {
        variation.push(att);
      }
    }
  }

  // "data" API was called more than once with the same arguments -
  // probably a mistake.
  if (variation.length === 0) {
    throw new Error("Error: cannot graph two identical queries");
  }

  // an "area" is just a list of points. The naive algorithm used to generate
  // data series names here would just display the entire list next to each
  // data series in the graph legend, which would be unhelpful, and an invalid
  // series name as far as C3 is concerned. At present, throw an error
  // if attempting to graph data series associated with different areas. If
  // this functionality is needed in the future, it can be implemented here.
  if (variation.indexOf("area") !== -1) {
    throw new Error(
      "Error: cannot display two datasets associated with different areas.",
    );
  }

  return function (run, context) {
    let name = "";
    for (let v of variation) {
      name = name.concat(`${context[v]} `);
    }
    name = name.concat(run);
    return name;
  };
}

/* dataToLongTermAverageGraph()
 * This function takes an array containins one or more JSON objects
 * from the "data" API call with this format:
 *
 * {
 *   "r1i1p1": {
 *     "data": {
 *       "1997-01-15T00:00:00Z": -19.534196834187902,
 *       "2055-01-15T00:00:00Z": -17.825752320828578,
 *       "1977-01-15T00:00:00Z": -20.599000150601793,
 *                    ...
 *       },
      "units": "degC"
 *   }
 * "r2i1p1":  {
 *           .........
 *  },
 *}
 *
 * and returns a C3 graph object displaying them.
 *
 * It takes an array containing an arbitrary number of data objects, each
 * containing an arbitrary number of runs, but no more than two separate
 * unit types.
 *
 * If there is more than one data object, an array of context objects is
 * needed as well, because the data API call returns no metadata beyond run
 * names. It's possible that two different datasets would share a run
 * name, and would appear identically on the graph, so additional context
 * is needed to to differentiate.
 * Each context object provides the attributes that were passed to the
 * API to generate the data object at the same array position.
 * For example:
 * {
 *   model_id: bcc-csm1-1-m
 *   variable_id: tasmax
 *   experiment: historical,+rcp45
 *   area: undefined
 * }
 *
 * The context objects are used in the graph legend, to distinguish runs
 * with the same name ("r1i1p1") from different datasets.
 */
function dataToLongTermAverageGraph(data, contexts = []) {
  // blank graph data object to be populated - holds data values
  // and individual-timeseries-level display options.
  let c3Data = {
    columns: [],
    types: {},
    labels: {},
    axes: {},
  };

  let seriesVariables = {};
  let seriesMetadata = {};
  let nameSeries;

  if (data.length === 1) {
    nameSeries = function (run) {
      return run;
    };
  } else if (data.length === contexts.length) {
    nameSeries = nameAPICallParametersFunction(contexts);
  } else {
    throw new Error("Error: no context provided for timeseries data");
  }

  // get the list of all timestamps and add them to the chart
  // (C3 requires x-axis timestamps be added as a data column)
  const timestamps = getAllTimestamps(data);
  c3Data.columns.push(["x"].concat(_.map(timestamps, dateToPeriod)));
  c3Data.x = "x";

  // add each API call to the chart
  for (let i = 0; i < data.length; i++) {
    const context = contexts.length ? contexts[i] : {};
    const call = data[i];

    // add each individual dataset from the API to the chart
    for (let run in call) {
      const runName = nameSeries(run, context);
      const seriesVariable = _.isEmpty(context)
        ? missingVariableName
        : context.variable_id;
      seriesVariables[runName] = seriesVariable;
      seriesMetadata[runName] = {
        variable: seriesVariable || "", // single-run has no var metadata
        units: getDataUnits(call[run], seriesVariable),
      };
      const series = [runName];

      // if a given timestamp is present in some, but not all
      // datasets, set the timestamp's value to "null"
      // in the C3 data object. This will cause C3 to render the
      // line with a break where the missing timestamp is.
      for (let t of timestamps) {
        series.push(
          _.isUndefined(call[run].data[t]) ? null : call[run].data[t],
        );
      }
      c3Data.columns.push(series);
      c3Data.types[runName] = "line";
    }
  }

  // whole-graph display options: axis formatting and tooltip behaviour
  let c3Axis = {};
  c3Axis.x = periodXAxis;

  // The long term average graph doesn't require every series to have the exact
  // same timestamps, since it's comparing long term trends anyway. Allow C3
  // to smoothly connect series even if they're "missing" timestamps.
  const c3Line = {
    connectNull: true,
  };

  let graph = {
    data: c3Data,
    axis: c3Axis,
    line: c3Line,
  };

  graph = assignDataToYAxis(graph, seriesMetadata);

  // Note: if context is empty (dataToLongTermAverageGraph was called with only
  // one time series), variable-determined precision will not be available and
  // numbers will be formatted with default precision.
  const precision = makePrecisionBySeries(seriesVariables);
  graph.tooltip = { format: {} };
  graph.tooltip.grouped = "true";
  graph.tooltip.format.value = makeTooltipDisplayNumbersWithUnits(
    graph.data.axes,
    graph.axis,
    precision,
  );

  return graph;
}

/* ************************************************************
 * 3. timeseriesToTimeseriesGraph
 **************************************************************/

/*
 * timeseriesToTimeseriesGraph()
 * This function takes one or more JSON objects from the
 * "timeseries" API call with this format:
 *
 * {
 * "id": "tasmax_mClim_BCCAQv2_bcc-csm1-1-m_historical-rcp45_r1i1p1_20700101-20991231_Canada",
 * "units": "degC",
 * "data": {
 *   "2085-01-15T00:00:00Z": -17.498223073165622,
 *   "2085-02-15T00:00:00Z": -15.54878007851129,
 *   "2085-03-15T00:00:00Z": -11.671093808333737,
 *                    ...
 *    }
 * }
 *
 * along with an array of dataset metadata entries that includes each
 * dataset referenced by the "id" field in the API results. It returns
 * a C3 graph object displaying each data object as a series.
 *
 * The graph produced by this function is intermediate between the
 * Annual Cycle graph and the Long Term Average graph, and uses a mixed
 * set of helper functions. It builds a chart from the same query and
 * data format as the Annual Cycle data, but produces an open-ended
 * timeseries with an arbitrary number of points and dates along the X
 * axis instead of a yearly cycle.
 *
 * Features a selectable "subchart" to let users zoom in to a smaller
 * scale, since data on this chart can consists of a very large
 * number of points. (monthly data 1950-2100 = 1800 points).
 *
 * Accepts an arbitrary number of data objects, but no more than
 * two separate unit types.
 */
function timeseriesToTimeseriesGraph(metadata, ...data) {
  // blank graph data object to be populated - holds data values
  // and individual-timeseries-level display options.
  let c3Data = {
    columns: [],
    types: {},
    labels: {},
    axes: {},
  };
  let seriesVariables = {};
  let seriesMetadata = {};

  const getTimeseriesName = shortestUniqueTimeseriesNamingFunction(
    metadata,
    data,
  );

  // get list of all timestamps
  const timestamps = getAllTimestamps(data);
  c3Data.columns.push(["x"].concat(_.map(timestamps, dateToPeriod)));
  c3Data.x = "x";

  // Add each timeseries to the graph
  for (let timeseries of data) {
    // get metadata for this timeseries
    const timeseriesMetadata = _.find(metadata, function (m) {
      return m.unique_id === timeseries.id;
    });
    const timeseriesName = getTimeseriesName(timeseriesMetadata);
    const seriesVariable = timeseriesMetadata.variable_id;
    seriesVariables[timeseriesName] = seriesVariable;
    seriesMetadata[timeseriesName] = {
      units: getDataUnits(timeseries, seriesVariable),
      variable: seriesVariable,
    };

    // add the actual data to the graph
    let column = [timeseriesName];

    for (let t of timestamps) {
      // assigns "null" for any timestamps missing from this series.
      // C3's behaviour toward null values is set by the line.connectNull attribute
      column.push(
        _.isUndefined(timeseries.data[t]) ? null : timeseries.data[t],
      );
    }

    c3Data.columns.push(column);
    c3Data.types[timeseriesName] = "line";
  }

  // whole-graph display options: axis formatting and tooltip behaviour
  let c3Axis = {};
  c3Axis.x = periodXAxis;
  const c3Subchart = { show: true, size: { height: 20 } };

  // instructs c3 to connect series across gaps where a timeseries is missing
  // a timestamp. While this could be confusing in cases where a datapoint
  // is actually missing from a series, it's helpful in cases where
  // series are at different time resolutions (monthly/yearly), so it's
  // included by default.
  const c3Line = {
    connectNull: true,
  };

  let graph = {
    data: c3Data,
    subchart: c3Subchart,
    axis: c3Axis,
    line: c3Line,
  };
  graph = assignDataToYAxis(graph, seriesMetadata);

  const precision = makePrecisionBySeries(seriesVariables);
  graph.tooltip = { format: {} };
  graph.tooltip.grouped = "true";
  graph.tooltip.format.value = makeTooltipDisplayNumbersWithUnits(
    graph.data.axes,
    graph.axis,
    precision,
  );

  return graph;
}

export {
  timeseriesToAnnualCycleGraph,
  dataToLongTermAverageGraph,
  timeseriesToTimeseriesGraph,
  // exported only for testing purposes:
  formatYAxis,
  fixedPrecision,
  makePrecisionBySeries,
  makeTooltipDisplayNumbersWithUnits,
  tooltipAddTimeOfYear,
  getMonthlyData,
  shortestUniqueTimeseriesNamingFunction,
  getAllTimestamps,
  nameAPICallParametersFunction,
};