pacificclimate/climate-explorer-frontend

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

Summary

Maintainability
D
1 day
Test Coverage
/************************************************************************
 * chart-transformers.js - functions that accept a C3 chart specification
 *   for a timeseries chart and alter the data to produce a different
 *   type of chart.
 *   
 * These functions accepts a C3 graph specification object and supplemental
 * parameters that vary by function. They return a C3 graph spec.
 * 
 * The main functions in this file are:
 * 
 *  - makeVariableReponseGraph: transforms a graph with one or more pairs 
 *    of timeseries representing different variables into a graph showing
 *    correlation between the variables (x and y axes are the variables)
 *  
 *  - makeAnomalyGraph: using one of the existing data series as the base, 
 *    adds additional data series representing the difference between the
 *    base series and each other data series
 *
 *  - makeTimeSliceGraph: accepts a graph showing one or more timeseries
 *    and a timestamp, returns a simplified narrow vertical graph showing
 *    only that timestamp. Intended for use as a sidebar.
 ***************************************************************************/
import _ from 'lodash';
import {caseInsensitiveStringSearch, getVariableOptions} from './util';
import {fixedPrecision} from './chart-generators';
import {assignColoursByGroup, hideSeriesInTooltip,
        hideSeriesInLegend, padYAxis,
        fadeSeriesByRank, hideTicksByRange} from './chart-formatters';

/***************************************************************************
 * 0. makeVariableReponseGraph and its helper functions
 ***************************************************************************/

/*
 * Graph transformation function that accepts two keywords (x and y) and a graph
 * containing one or more pairs of timeseries and combines pairs of matching time 
 * series into a variable response graph.
 * 
 * Each data series should match exactly one other series. In order to match, two 
 * data series must:
 *   - have names that are identical except for the substitution of x for y
 *   - have data at all the same timestamps
 * 
 * This function combines each pair of matching data series into a new data series. For
 * each (time, data) tuple present in both original time series, it creates a new
 * (data-x, data-y) tuple, using the series with the x keyword as the x coordinate
 * and the series with the y keyword as the y coordinate.
 *
 * The axis labels of the new graph will be generated from the y axis label(s) of the
 * old graph.
 *
 * Example:
 * x: pr
 * y: tasmax
 * chart with data.columns:
 * ["Monthly pr", 10, 20, 30, 40, 50 ]
 * ["Monthly tasmax", 1, 2, 3, 4, 5 ]
 * ["x", 1/1/15, 1/2/15, 1/3/15, 1/4/15, 1/5/15]
 * 
 * Would result in a new chart with data.columns:
 * ["x", 10, 20, 30, 40, 50]
 * ["pr", 1, 2, 3, 4, 5]
 * 
 * This is intended to graph the effect of one variable (x) on another (y).
 */
function makeVariableResponseGraph (x, y, graph) {
  let c3Data = {};

  const seriesNameContains = function (series, keyword) {
    return caseInsensitiveStringSearch(series[0], keyword);
  }
  
  const xseries = _.filter(graph.data.columns, series => seriesNameContains(series, x));
  const yseries = _.filter(graph.data.columns, series => seriesNameContains(series, y));

  let tuples = [];
  let seriesMatched = false;
  for(const independent of xseries) {
    //Try to match each dependent variable series with an independent variable series
    let dependent = _.find(yseries, series => {
      return series[0].toLowerCase().replace(y.toLowerCase(), x.toLowerCase()) === 
        independent[0].toLowerCase();
      });
    if(dependent) {
      seriesMatched = true;
      for(let n = 1; n < independent.length; n++) {
        if(!_.isNull(independent[n]) && !_.isNull(dependent[n])) {
          tuples.push([independent[n], dependent[n]]);
        }
      }
    }
  }
  if(!seriesMatched) {
    throw new Error("Unable to correlate variables");
  }
  
  //sort by x value, preperatory to putting on the graph.
  tuples.sort((a, b) => a[0] - b[0]);  
  
  //C3 doesn't really support scatterplots, but we can fake it by adding
  //a missing data point between each actual data point, and instructing C3
  //not to connect across missing data points with {connectNull: false} 
  //TODO: break this out into makeScatterplot(); we'll likely need it again
  c3Data.columns = [];
  c3Data.columns.push(_.reduce(tuples, (memo, tuple, index, list) => {
    memo.push(tuple[0]);
    if(index < list.length - 1) {
      memo.push(tuple[0] / 2 + list[index + 1][0] / 2);
    }
    return memo;
  }, ["x"]));  
  c3Data.columns.push(_.reduce(tuples, (memo, tuple, index, list) => {
    index < list.length - 1 ? memo.push(tuple[1], null) : memo.push(tuple[1]);
    return memo;
  }, [y]));
  
  // Generate x and y axes. Reuse labels from source graph,
  // but add variable names if not present.
  let xAxisLabel = getAxisTextForVariable(graph, x);
  xAxisLabel = xAxisLabel.search(x) === -1 ? `${x} ${xAxisLabel}` : xAxisLabel;
  const xAxis = {
      tick: {
        count: 8,
        fit: true,
        format: fixedPrecision
      },
      label: xAxisLabel
    };

  let yAxisLabel = getAxisTextForVariable(graph, y);
  yAxisLabel = yAxisLabel.search(y) === -1 ? `${y} ${yAxisLabel}` : yAxisLabel;
  const yAxis = {
      tick: {
        format: fixedPrecision
      },
      label: yAxisLabel
  };

  //Whole-graph formatting options
  c3Data.x = 'x'; //use x series
  const c3Line = {connectNull: false}; //don't connect point data
  const c3Tooltip = {show: false}; //no tooltip or legend, simplify graph.
  const c3Legend = {show: false};

  return {
    data: c3Data,
    line: c3Line,
    tooltip: c3Tooltip,
    legend: c3Legend,
    axis: {
      y: yAxis,
      x: xAxis
    },
  };
};

/*
 * Helper function for makeVariableResponseGraph: given a graph and a
 * variable name, returns the axis label text associated with that variable.
 */
function getAxisTextForVariable (graph, variable) {
  let series = graph.data.columns.find(s => {
    return caseInsensitiveStringSearch(s[0], variable);
    });
  
  if(_.isUndefined(series)) {
    throw new Error("Cannot build variable response chart from single variable chart");
  }
  series = series[0];

  //see if this series has an explicit axis association, default to y if not.
  const axis = graph.data.axes[series] ? graph.data.axes[series] : 'y';

  return _.isString(graph.axis[axis].label) ?
      graph.axis[axis].label :
      graph.axis[axis].label.text;
};

/***************************************************************************
 * 1. makeAnomalyGraph and its helper functions
 ***************************************************************************/
/*
 * Graph transformation function that accepts a graph containing one or more
 * timeseries associated with a single axis, the name of the displayed variable,
 * and the name of one of the timeseries to use as a base.
 *
 * Adds a secondary y axis, graphing a data series showing the difference
 * (anomaly) between each of the original series and the base series.
 *
 * The anomaly series will have the same hue, but somewhat desaturated colour
 * as the original series they represent.They will not appear in legends or
 * tooltips, but their name (if needed for further graph manipulation)
 * will be "[original name] Anomaly."
 *
 * This is intended to display change over time on a single graph.
 *
 * The variable argument should be left undefined if multiple
 * variables are shown on the graph.
 */

function makeAnomalyGraph (base, variable_id, graph) {

  //anomalies for some variables are typically expressed as percentages.
  //if this is a single variable graph, check the variable configuration 
  //to see if this is one of them; if so, display percentages on the chart.
  const displayPercent = !_.isUndefined(variable_id) &&
                         getVariableOptions(variable_id, "percentageAnomalies");
  
  if(!_.isUndefined(graph.axis.y2)) {
    throw new Error("Error: Cannot calculate anomalies for multiple data types.");
  }
  
  const baseSeries = _.find(graph.data.columns, series => {return series[0] === base});
  if(_.isUndefined(baseSeries)) {
    throw new Error("Error: Invalid base data for anomaly calculation.");
  }
  const baseSeriesName = baseSeries[0];
  
  const origLength = graph.data.columns.length;
  graph.data.axes = {};
  graph.axis.y2 = {show: true};
  
  for(let i = 0; i < origLength; i++) {
    let seriesName = graph.data.columns[i][0];
    if(seriesName !== 'x' && seriesName !== base) {
      let oldSeries = graph.data.columns[i];
      if(oldSeries.length !== baseSeries.length) {
        throw new Error("Error: Incorrect data series length, cannot calculate anomaly");
      }

      let newSeries = [];
      newSeries.push(`${seriesName} Anomaly`);
      for(let j = 1; j < oldSeries.length; j++){
        newSeries.push(
          displayPercent ?
            percentageChange(baseSeries[j], oldSeries[j]) :
            oldSeries[j] - baseSeries[j]
        );
      }
      graph.data.columns.push(newSeries);
      graph.data.axes[seriesName] = 'y';
      graph.data.axes[`${seriesName} Anomaly`] = 'y2';
      graph.data.types[`${seriesName} Anomaly`] = oldSeries[0] === base? "line" : "bar";
    }
  }
  graph.axis.y2.label = {};
  graph.axis.y2.label.position = 'outer-middle';
  
  let oldAxisText = getAxisTextForVariable(graph, baseSeriesName);
  oldAxisText = oldAxisText.replace(variable_id, ""); // avoid repetition with base series name 
  graph.axis.y2.label.text = displayPercent ?
      `% change from ${baseSeriesName}` :
      `change in ${oldAxisText} from ${baseSeriesName}`;
  graph.axis.y2.tick = {};
  graph.axis.y2.tick.format = graph.axis.y.tick.format;
  
  // function that determines whether a data series an anomaly series.
  // used to format anomalies differently than data
  function isAnomaly (series) {
    return caseInsensitiveStringSearch(series[0], "Anomaly");
  }
  
  // classifier function that matches each "anomaly" data series with
  // the nominal series it is based on. Used to match colours.
  function anomalyMatcher (series) {
    const sName = series[0];
    return isAnomaly(series) ? sName.substring(0, sName.length - 8) : sName;
  };  
  
  // ranking function that assigns anomaly series lower results than 
  // nominal series, used to make them distinguishable.
  function anomalyRanker (series) {
    return isAnomaly(series) ? .7 : 1;
  }
  
  //assign anomaly data series the same colour as the series they describe.
  graph = assignColoursByGroup(graph, anomalyMatcher);

  
  //remove anomaly series from tooltips and legends, lighten anomalies
  graph = hideSeriesInTooltip(graph, isAnomaly);
  graph = hideSeriesInLegend(graph, isAnomaly);
  graph = fadeSeriesByRank(graph, anomalyRanker);
  
  //show anomalies with nominal values in tooltip:
  graph.tooltip.format.value = addAnomalyTooltipFormatter(graph.tooltip.format.value, baseSeries, displayPercent);
  
  //move the two sets of data apart for less confusing visuals
  graph = padYAxis(graph, 'y2', 'top', 1.1);
  graph = padYAxis(graph, 'y', 'bottom', 1.1);
  
  graph = hideTicksByRange(graph, "y");
  graph = hideTicksByRange(graph, "y2");

  return graph;
};

/*
 * Helper function for makeAnomalyGraph: returns the percent difference
 * of two values.
 */
function percentageChange(a, b) {
  if (a === 0) {
    //this shouldn't happen, as percentage changes are only used for
    //whitelisted variables like precip, which should always have a > 0.
    //but if it does (bad data), return null, which will make the graph
    //skip this data point.
    return null;
  }
  return 100 * (b - a) / Math.abs(a);
}

/*
 * Helper function for makeAnomalyGraph: takes an existing tool tip number
 * formatting function, and adds a wrapper which appends the anomaly from
 * the specified base series, either as a percent or nominal value.
 */
function addAnomalyTooltipFormatter (oldFormatter, baseSeries, displayPercent) {  
  const newTooltipValueFormatter = function(value, ratio, id, index) {
    let nominal = oldFormatter(value, ratio, id, index);
    if(_.isUndefined(nominal)) { //this series doesn't display in tooltip.
      return undefined; 
    }
    else {
      const anomaly = displayPercent ?
          percentageChange(baseSeries[index+1], value) :
          value - baseSeries[index+1];
      const sign = anomaly >= 0 ? "+" : "";
      const percent = displayPercent ? "%" : "";
      const anomPrint = _.isNull(anomaly) ? "-" : anomaly.toPrecision(2);
      return nominal + " (" + sign + anomPrint + percent +")";
    }
  };
  return newTooltipValueFormatter;
};

/***************************************************************************
 * 1. makeTimeSliceGraph and its helper functions
 ***************************************************************************/
/*
 * Given a timeseries graph and a string matching a timestamp in that graph,
 * returns a new graph containing only data present at that particular moment.
 *
 * Can generate a timeslice from a C3 graph specification with an x-axis of
 * either "timeseries" (like a Long Term Average graph) or "category" (like an
 * Annual Cycle graph) type, but not from a graph with an "indexed" type x axis.
 */

function makeTimeSliceGraph (timestamp, graph) {
  let slicedData = [];
  let timestamps = [];
  let sliceIndex = -1;

  if(graph.axis.x.type == "timeseries") {
    //x-axis has a series of dates
    timestamps = graph.data.columns.find(function(series) {return series[0] === 'x'});
  }
  else if(graph.axis.x.type == "category") {
    //x-axis is text, most likely month names
    timestamps = graph.axis.x.categories;
  }
  else {
    throw new Error("Error: timeslice graph must be generated from a timeseries");
  }

  if(_.isUndefined(timestamps)) {
    throw new Error("Error: time information missing from source graph");
  }
  
  sliceIndex = timestamps.indexOf(timestamp);
  if(sliceIndex === -1) {
    throw new Error("Error: invalid timestamp selected");
  }

  for(let i = 0; i < graph.data.columns.length; i++) {
    let series = graph.data.columns[i];
    if(!_.isUndefined(series[sliceIndex]) && series[0] !== 'x'){
      slicedData.push([series[0], series[sliceIndex]]);
    }
  }

  //sort the data series by value, to make matching with the legend easier
  slicedData.sort((a, b) => {return b[1] - a[1]});

  graph.data.columns = slicedData;

  //remove timeseries-related formatting
  let date = new Date(timestamp);
  graph.axis.x = {
      type: 'category',
      categories: [date.getFullYear()]
  };
  graph.data.x = undefined;
  graph.tooltip = {show: false};

  return graph;
}

export { makeVariableResponseGraph, makeAnomalyGraph,
    makeTimeSliceGraph,
    //exported only for testing purposes:
    getAxisTextForVariable, percentageChange};