Models/LegendHelper.js

"use strict";

/*global require*/
var clone = require("terriajs-cesium/Source/Core/clone").default;
var Color = require("terriajs-cesium/Source/Core/Color").default;
var defaultValue = require("terriajs-cesium/Source/Core/defaultValue").default;
var defined = require("terriajs-cesium/Source/Core/defined").default;

var standardCssColors = require("../Core/standardCssColors");
var hashFromString = require("../Core/hashFromString");
var formatNumberForLocale = require("../Core/formatNumberForLocale");
var Legend = require("../Map/Legend");
var TableStyle = require("../Models/TableStyle");

var ckmeans = require("simple-statistics/src/ckmeans").default;
var quantile = require("simple-statistics/src/quantile").default;

var defaultScalarColorMap = [
  { offset: 0.0, color: "rgba(239,210,193,1.0)" },
  { offset: 0.25, color: "rgba(221,139,116,1.0)" },
  { offset: 0.5, color: "rgba(255,127,46,1.0)" },
  { offset: 0.75, color: "rgba(255,65,43,1.0)" },
  { offset: 1.0, color: "rgba(111,0,54,1.0)" }
];

var defaultEnumColorCodes = standardCssColors.brewer9ClassSet1;
var defaultLargeEnumColorCodes = standardCssColors.highContrast;
var defaultColorArray = [32, 32, 32, 128]; // Used if no selected variable (and no regions).
var noColorArray = [0, 0, 0, 0];

var defaultNullLabel = "(No value)";
var defaultNoColumnColorCodes = standardCssColors.highContrast;
var defaultNoColumnColorAlpha = 1.0;

var defaultNumberOfColorBins = 7;

/**
 * Legends for table columns depend on both the table style and the selected column.
 * This class brings the two together to generate a legend.
 * Its key output is legendUrl.
 *
 * @alias LegendHelper
 * @constructor
 *
 * @param {TableColumn} tableColumn The column whose values inform the legend.
 * @param {TableStyle} [tableStyle] The styling for the table.
 * @param {RegionProvider} [regionProvider] The region provider, if region mapped. Used if no table column set.
 * @param {String} [name] A name used in the legend if no active column is selected.
 */
var LegendHelper = function(tableColumn, tableStyle, regionProvider, name) {
  this.tableColumn = tableColumn;
  this.tableStyle = defined(tableStyle) ? tableStyle : new TableStyle(); // instead of defaultValue, so new object only created if needed.
  this.tableColumnStyle = getTableColumnStyle(tableColumn, this.tableStyle);
  this.name = name;
  var noColumnIndex =
    hashFromString(name || "") % defaultNoColumnColorCodes.length;
  this._noColumnColorArray = getColorArrayFromCssColorString(
    defaultNoColumnColorCodes[noColumnIndex],
    defaultNoColumnColorAlpha
  );
  this._legend = undefined; // We could make a getter for this if it is ever needed.
  this._colorGradient = undefined;
  this._binColors = undefined; // An array of objects with upperBound and colorArray properties.
  this._regionProvider = regionProvider;
  if (defined(this.tableColumnStyle.nullColor)) {
    this._nullColorArray = getColorArrayFromCssColorString(
      this.tableColumnStyle.nullColor
    );
  } else {
    this._nullColorArray = defined(regionProvider)
      ? noColorArray
      : defaultColorArray;
  }
  this._cycleEnumValues = false;
  this._cycleColors = undefined; // Array of colors used for the cycle method

  this.tableColumnStyle.legendTicks = defaultValue(
    this.tableColumnStyle.legendTicks,
    0
  );
  this.tableColumnStyle.scale = defaultValue(this.tableColumnStyle.scale, 1);
};

// Find the right table column style for this column.
// By default, take styling directly from the tableStyle, unless there is a suitable 'columns' entry.
function getTableColumnStyle(tableColumn, tableStyle) {
  var tableColumnStyle;
  if (defined(tableColumn) && defined(tableStyle.columns)) {
    if (defined(tableStyle.columns[tableColumn.id])) {
      tableColumnStyle = clone(tableStyle.columns[tableColumn.id]);
    } else {
      // Also support column indices as keys into tableStyle.columns
      var tableStructure = tableColumn.parent;
      var columnIndex = tableStructure.columns.indexOf(tableColumn);
      if (defined(tableStyle.columns[columnIndex])) {
        tableColumnStyle = clone(tableStyle.columns[columnIndex]);
      }
    }
  }
  if (!defined(tableColumnStyle)) {
    return tableStyle;
  }
  // Copy defaults from tableStyle too.
  for (var propertyName in tableStyle) {
    if (
      tableStyle.hasOwnProperty(propertyName) &&
      tableColumnStyle.hasOwnProperty(propertyName)
    ) {
      if (!defined(tableColumnStyle[propertyName])) {
        tableColumnStyle[propertyName] = tableStyle[propertyName];
      }
    }
  }
  return tableColumnStyle;
}

/**
 * Generates intermediate variables (such as _colorGradient, _binColors) and saves the legend.
 * This could be exposed in an API if needed.
 * @private
 */
function generateLegend(legendHelper) {
  var legendProps;
  if (
    !defined(legendHelper.tableColumn) ||
    !defined(legendHelper.tableColumn.values)
  ) {
    // If no table column is active, color it as if it were an ENUM with the maximum available colors.
    if (legendHelper.regionProvider) {
      legendHelper._binColors = buildEnumBinColors(
        legendHelper,
        legendHelper.regionProvider.regions,
        "top",
        undefined
      );
      legendProps = buildEnumLegendProps(
        legendHelper,
        legendHelper.regionProvider.regions
      );
    } else {
      legendProps = defined(legendHelper.name)
        ? {
            items: [
              {
                title: legendHelper.name,
                color: convertColorArrayToCssString(
                  legendHelper._noColumnColorArray
                )
              }
            ]
          }
        : undefined;
    }
  } else if (legendHelper.tableColumn.isEnum) {
    var tableColumnStyle = legendHelper.tableColumnStyle;
    var uniqueValues = legendHelper.tableColumn.uniqueValues;
    legendHelper._binColors = buildEnumBinColors(
      legendHelper,
      uniqueValues,
      tableColumnStyle.colorBinMethod,
      tableColumnStyle.colorBins
    );
    legendProps = buildEnumLegendProps(legendHelper, uniqueValues);
  } else {
    var colorMap = defaultValue(
      legendHelper.tableColumnStyle.colorMap,
      defaultScalarColorMap
    );
    var colorBins = defaultValue(
      legendHelper.tableColumnStyle.colorBins,
      defaultNumberOfColorBins
    );

    legendHelper._colorGradient = buildColorGradient(colorMap);
    legendHelper._binColors = buildBinColors(legendHelper, colorBins);
    legendProps = buildLegendProps(legendHelper, colorMap);
  }
  if (defined(legendProps)) {
    legendHelper._legend = new Legend(legendProps);
  } else {
    legendHelper._legend = null; // use null so that we know it tried and failed, so don't try again.
  }
}

function buildEnumBinColors(legendHelper, uniqueValues, method, colorBins) {
  colorBins = defaultValue(colorBins, uniqueValues.length);
  legendHelper._cycleEnumValues = false;
  legendHelper._otherColor = getColorArrayFromCssColorString(
    defaultLargeEnumColorCodes[defaultLargeEnumColorCodes.length - 1]
  ); // Default "other" colour
  var binLookup = {};
  var i;
  if (Array.isArray(colorBins)) {
    // colorBins is an array of {value:"val", color:"col"} objects
    // Methods are irrelevant here.
    for (i = 0; i < colorBins.length; i++) {
      var bin = colorBins[i];
      if (defined(bin.value)) {
        // Ignore bins with values that aren't in the column
        if (uniqueValues.indexOf(bin.value) >= 0) {
          binLookup[bin.value] = getColorArrayFromCssColorString(bin.color);
        }
      } else {
        legendHelper._otherColor = getColorArrayFromCssColorString(bin.color);
      }
    }
  } else {
    // colorBins is an Integer
    // Calculate the number of different colours and take that many colours from a default colour set
    var binCount = Math.min(
      colorBins,
      uniqueValues.length,
      defaultLargeEnumColorCodes.length
    );
    var colorCodes =
      binCount <= defaultEnumColorCodes.length
        ? defaultEnumColorCodes
        : defaultLargeEnumColorCodes;
    colorCodes = colorCodes.slice(0, binCount).map(function(cssString) {
      return getColorArrayFromCssColorString(cssString);
    });

    method = method.toLowerCase();
    if (method === "auto") {
      method = "top";
    }
    // Number of values that should get explicit colours. Other values will be coloured with the "other" colour
    var valuesCount = 0;
    if (method === "top") {
      // If too many values, use the first colorCodes.length-1 colours for the first values, and colorCodes[-1] for other values
      valuesCount =
        uniqueValues.length <= colorCodes.length
          ? uniqueValues.length
          : colorCodes.length - 1;
    } else if (method === "cycle") {
      // Assign colours to all values
      valuesCount = uniqueValues.length;
      if (valuesCount > colorCodes.length) {
        legendHelper._cycleEnumValues = true;
        legendHelper._cycleColors = colorCodes;
      }
    }
    // Assign colours to the first valuesCount uniqueValues entries
    for (i = 0; i < valuesCount; i++) {
      binLookup[uniqueValues[i]] = colorCodes[i % colorCodes.length];
    }
  }
  return binLookup;
}

function buildEnumLegendProps(legendHelper, uniqueValues) {
  var tableColumn = legendHelper.tableColumn;
  var tableColumnStyle = legendHelper.tableColumnStyle;
  var binColors = legendHelper._binColors;
  var nullLabel = defaultValue(tableColumnStyle.nullLabel, defaultNullLabel);
  var title = tableColumn.name;

  // ENUM legend labels are centered on each box, and slightly separated.
  // Reverse the color bins so that the first one appears at the top, not the bottom.

  var items;
  if (legendHelper._cycleEnumValues) {
    items = [
      {
        title: variousValuesTitle(tableColumn),
        multipleColors: legendHelper._cycleColors.map(function(color) {
          return convertColorArrayToCssString(color);
        })
      }
    ];
  } else {
    items = [];
    var count = 0;
    for (var value in binColors) {
      if (Object.prototype.hasOwnProperty.call(binColors, value)) {
        items.push({
          title: defaultValue(value, nullLabel),
          color: convertColorArrayToCssString(binColors[value])
        });
        count++;
      }
    }
    if (uniqueValues.length > count) {
      items.push({
        title: uniqueValues.length - count + " other values",
        color: convertColorArrayToCssString(legendHelper._otherColor)
      });
    }
  }

  items.reverse();

  var result = {
    title: title,
    itemSpacing: 2,
    items: items
  };

  // Add a null color at the bottom (ie front of the array) if there are any null values
  if (defined(tableColumn) && tableColumn.values.indexOf(null) >= 0) {
    result.items.unshift({
      title: nullLabel,
      color: convertColorArrayToCssString(legendHelper._nullColorArray),
      spacingAbove: 0
    });
  }
  return result;
}

/**
 * Returns the legendUrl for this legend.  Can be called directly after instantiation.
 * @return {LegendUrl} The Legend URL object for the legend, with its url being a base64-encoded PNG.
 */
LegendHelper.prototype.legendUrl = function() {
  if (!defined(this._legend)) {
    generateLegend(this);
  }
  if (defined(this._legend)) {
    return this._legend.getLegendUrl();
  }
};

/**
 * Convert a value to a fractional value, eg. in a column that ranges from 0 to 100, 20 -> 0.2.
 * TableStyle can override the minimum and maximum of the range.
 * @private
 * @param  {Number} value The value.
 * @return {Number} The fractional value.
 */
function getFractionalValue(legendHelper, value) {
  var extremes = getExtremes(
    legendHelper.tableColumn,
    legendHelper.tableColumnStyle
  );
  var f =
    extremes.maximum === extremes.minimum
      ? 0
      : (value - extremes.minimum) / (extremes.maximum - extremes.minimum);
  if (legendHelper.tableColumnStyle.clampDisplayValue) {
    f = Math.max(0.0, Math.min(1.0, f));
  }
  return f;
}

/**
 * Maps an absolute value to a scale, based on tableColumnStyle.
 * @param  {Number} [value] The absolute value.
 * @return {Number} The scale.
 */
LegendHelper.prototype.getScaleFromValue = function(value) {
  var scale = this.tableColumnStyle.scale;
  if (this.tableColumnStyle.scaleByValue) {
    var fractionalValue = defined(value) ? getFractionalValue(this, value) : 0; // Missing values are scaled like 0.
    if (defined(fractionalValue) && fractionalValue === fractionalValue) {
      // testing for NaN
      scale = scale * (fractionalValue + 0.5);
    } else {
      scale = 0.5; // NaNs are scaled like 0 too.
    }
  }
  return scale;
};

/**
 * Maps an absolute value to a color array, based on the legend.
 * @param  {Number} [value] The absolute value.
 * @return {Number[]} The color, as an array [r, g, b, a].
 *         If there is no table column selected, use a random colour.
 *         If the value is null, use the nullColor.
 *         If no value is provided, or no color bins are defined, use the nullColor.
 */
LegendHelper.prototype.getColorArrayFromValue = function(value) {
  if (!defined(this.tableColumn)) {
    return this._noColumnColorArray;
  }
  if (!defined(value)) {
    // Note "defined" also checks value !== null, so this catches value === undefined or null.
    return this._nullColorArray;
  }
  if (this.tableColumnStyle.colorBins === 0) {
    return getColorArrayFromColorGradient(
      this._colorGradient,
      getFractionalValue(this, value)
    );
  }
  if (this.tableColumn.isEnum) {
    return Object.prototype.hasOwnProperty.call(this._binColors, value)
      ? this._binColors[value]
      : this._otherColor;
  }
  if (!defined(this._binColors) || this._binColors.length === 0) {
    return this._nullColorArray;
  }

  var i = 0;
  while (
    i < this._binColors.length - 1 &&
    value > this._binColors[i].upperBound
  ) {
    i++;
  }
  if (!defined(this._binColors[i])) {
    // is this actually possible given the checks above?
    console.log("Bad bin " + i);
    return [0, 0, 0, 0];
  }
  return this._binColors[i].colorArray;
};

/**
 * Maps an absolute value to a Color, based on the legend.
 * @param  {Number} [value] The absolute value.
 * @return {Color} The color. If no value is provided, uses a default color.
 */
LegendHelper.prototype.getColorFromValue = function(value) {
  return colorArrayToColor(this.getColorArrayFromValue(value));
};

/**
 * A helper function to convert an array to a color.
 * @private
 * @param  {Array} [colorArray] An array of RGBA values from 0 to 255. Even alpha is 0-255. Defaults to [32, 0, 200, 255].
 * @return {Color} The Color object.
 */
function colorArrayToColor(colorArray) {
  return new Color(
    colorArray[0] / 255,
    colorArray[1] / 255,
    colorArray[2] / 255,
    colorArray[3] / 255
  );
}

function getColorArrayFromCssColorString(cssString, alphaOverride) {
  // alphaOverride is an optional fraction from 0 - 1.
  var canvas = document.createElement("canvas");
  if (!defined(canvas)) {
    return defaultColorArray; // Failed
  }
  var ctx = canvas.getContext("2d");
  ctx.fillStyle = cssString;
  ctx.fillRect(0, 0, 2, 2);
  var result = ctx.getImageData(0, 0, 1, 1).data;
  if (defined(alphaOverride)) {
    result[3] = Math.round(255 * alphaOverride);
  }
  return result;
}

function buildColorGradient(colorMap) {
  if (!defined(colorMap)) {
    return;
  }
  var canvas = document.createElement("canvas");
  if (!defined(canvas)) {
    return;
  }
  var w = (canvas.width = 64);
  var h = (canvas.height = 256);
  var ctx = canvas.getContext("2d");

  // Create Linear Gradient
  var linGrad = ctx.createLinearGradient(0, 0, 0, h - 1);
  for (var i = 0; i < colorMap.length; i++) {
    linGrad.addColorStop(colorMap[i].offset, colorMap[i].color);
  }
  ctx.fillStyle = linGrad;
  ctx.fillRect(0, 0, w, h);

  var colorGradient = ctx.getImageData(0, 0, 1, 256);
  return colorGradient;
}

function getColorArrayFromColorGradient(colorGradient, fractionalPosition) {
  var colorIndex =
    Math.floor(fractionalPosition * (colorGradient.data.length / 4 - 1)) * 4;
  return [
    colorGradient.data[colorIndex],
    colorGradient.data[colorIndex + 1],
    colorGradient.data[colorIndex + 2],
    colorGradient.data[colorIndex + 3]
  ];
}

function getExtremes(tableColumn, tableColumnStyle) {
  if (!defined(tableColumn)) {
    return {};
  }
  var minimumValue = tableColumn.minimumValue;
  var maximumValue = tableColumn.maximumValue;
  if (minimumValue !== maximumValue && defined(tableColumnStyle)) {
    if (defined(tableColumnStyle.maxDisplayValue)) {
      maximumValue = tableColumnStyle.maxDisplayValue;
    }
    if (defined(tableColumnStyle.minDisplayValue)) {
      minimumValue = tableColumnStyle.minDisplayValue;
    }
  }
  return { minimum: minimumValue, maximum: maximumValue };
}

/**
 * Builds and returns an array describing the legend colors.
 * Each element is an object with keys "color" and "upperBound", eg.
 * [ { color: [r, g, b, a], upperBound: 20 } , { color: [r, g, b, a]: upperBound: 80 } ]
 * @private
 * @param {LegendHelper} legendHelper The legend helper.
 * @param {Integer|Number[]} colorBins The number of color bins to use, or the boundaries to use.
 * @return {Array} Array of objects with keys "color" and "upperBound".
 */
function buildBinColors(legendHelper, colorBins) {
  var tableColumn = legendHelper.tableColumn;
  var tableColumnStyle = legendHelper.tableColumnStyle;
  var colorGradient = legendHelper._colorGradient;

  // If colorBins is an array, just return it in the right format.
  var extremes = getExtremes(tableColumn, tableColumnStyle);
  if (
    Array.isArray(colorBins) &&
    defined(extremes.minimum) &&
    defined(extremes.maximum)
  ) {
    // If the max value is beyond the range, add it to the end.
    // Do this to be symmetric with min and max.
    if (colorBins[colorBins.length - 1] < extremes.maximum) {
      colorBins = colorBins.concat(extremes.maximum);
    }
    var numberOfColorBins = colorBins.length;
    var filteredBins = colorBins.filter(function(bound, i) {
      // By cutting off all bins equal to or lower than the min value,
      // the min value will be added as a titleBelow instead of titleAbove.
      // Since any bins wholy below the min are removed, do the same with max.
      return (
        bound > extremes.minimum &&
        (i === 0 || colorBins[i - 1] < extremes.maximum)
      );
    });
    // Offset to make sure that the correct color is used when the legend is truncated
    var binOffset = colorBins.indexOf(filteredBins[0]);
    return filteredBins.map(function(bound, i) {
      return {
        // Just use the provided bound, but cap it at the max value.
        upperBound: Math.min(bound, extremes.maximum),
        colorArray: getColorArrayFromColorGradient(
          colorGradient,
          (binOffset + i) / (numberOfColorBins - 1)
        )
      };
    });
  }

  if (colorBins <= 0 || tableColumnStyle.colorBinMethod.match(/none/i)) {
    return undefined;
  }
  var binColors = [];
  var i;
  var numericalValues = tableColumn.numericalValues;

  if (numericalValues.length === 0) {
    return [];
  }

  // Must ask for fewer clusters than the number of items.
  var binCount = Math.min(colorBins, numericalValues.length);

  var method = tableColumnStyle.colorBinMethod.toLowerCase();
  if (method === "auto") {
    if (numericalValues.length > 1000) {
      // The quantile method is simpler and less accurate, but faster for large datasets.
      method = "quantile";
    } else {
      method = "ckmeans";
    }
  }

  if (method === "quantile") {
    // One issue is we don't check to see if any values actually lie within a given quantile, so it's bad for small datasets.
    for (i = 0; i < binCount; i++) {
      binColors.push({
        upperBound: quantile(numericalValues, (i + 1) / binCount),
        colorArray: getColorArrayFromColorGradient(
          colorGradient,
          i / (binCount - 1)
        )
      });
    }
  } else if (method === "ckmeans") {
    var clusters = ckmeans(numericalValues, binCount);
    // Convert the ckmeans format [ [5, 20], [65, 80] ] into our format.
    for (i = 0; i < clusters.length; i++) {
      if (
        i > 0 &&
        clusters[i].length === 1 &&
        clusters[i][0] === clusters[i - 1][clusters[i - 1].length - 1]
      ) {
        // When there are few unique values, we can end up with clusters like [1], [2],[2],[2],[3]. Let's avoid that.
        continue;
      }
      binColors.push({
        upperBound: clusters[i][clusters[i].length - 1]
      });
    }
    if (binColors.length > 1) {
      for (i = 0; i < binColors.length; i++) {
        binColors[i].colorArray = getColorArrayFromColorGradient(
          colorGradient,
          i / (binColors.length - 1)
        );
      }
    } else {
      // only one binColor, pick the middle of the color gradient.
      binColors[0].colorArray = getColorArrayFromColorGradient(
        colorGradient,
        0.5
      );
    }
  }
  return binColors;
}

function convertToStringWithAtMostTwoDecimalPlaces(f, tableColumnStyle) {
  // If no format.maximumFractionDigits set, set it to two.
  var options;
  if (defined(tableColumnStyle.format)) {
    options = clone(tableColumnStyle.format);
    options.maximumFractionDigits = defaultValue(
      tableColumnStyle.format.maximumFractionDigits,
      2
    );
  } else {
    options = { maximumFractionDigits: 2 };
  }
  return formatNumberForLocale(f, options);
}

function convertColorArrayToCssString(colorArray) {
  return (
    "rgba(" +
    colorArray[0] +
    "," +
    colorArray[1] +
    "," +
    colorArray[2] +
    ", " +
    colorArray[3] / 255.0 +
    ")"
  );
}

function variousValuesTitle(tableColumn) {
  return tableColumn.uniqueValues.length + " values";
}

function buildLegendProps(legendHelper, colorMap) {
  var tableColumn = legendHelper.tableColumn;
  var tableColumnStyle = legendHelper.tableColumnStyle;
  var binColors = legendHelper._binColors;

  var extremes = getExtremes(tableColumn, tableColumnStyle);

  function gradientLabelPoints(ticks) {
    var items = [];
    var segments = 2 + ticks;
    for (var i = 1; i <= segments; i++) {
      items.push({
        titleAbove: convertToStringWithAtMostTwoDecimalPlaces(
          extremes.minimum +
            (extremes.maximum - extremes.minimum) * (i / segments),
          tableColumnStyle
        ),
        titleBelow:
          i === 1
            ? convertToStringWithAtMostTwoDecimalPlaces(
                extremes.minimum,
                tableColumnStyle
              )
            : undefined
      });
    }

    // Add a null color at the bottom (ie front of the array) if there are any null values
    if (tableColumn.values.indexOf(null) >= 0) {
      items.unshift({
        title: nullLabel,
        color: convertColorArrayToCssString(legendHelper._nullColorArray),
        spacingAbove: 8
      });
    }

    return items;
  }

  var result;
  var nullLabel = defaultValue(tableColumnStyle.nullLabel, defaultNullLabel);
  var title = defaultValue(
    legendHelper.tableColumnStyle.legendName,
    tableColumn.name
  );

  if (!binColors) {
    // Display a smooth gradient with number of ticks requested.
    return {
      title: title,
      barHeightMin: 130,
      gradientColorMap: colorMap,
      labelTickColor: "darkgray",
      items: gradientLabelPoints(tableColumnStyle.legendTicks)
    };
  } else {
    // Numeric legends are displayed as thresholds between touching colors,
    // and have an additional value at the bottom.
    result = {
      title: title,
      itemSpacing: 0,
      items: binColors.map(function(b, i) {
        return {
          // these long checks are to avoid showing max and min values when they're identical to the second highest and second lowest numbers
          titleAbove:
            i === 0 ||
            i < binColors.length - 1 ||
            b.upperBound > binColors[i - 1].upperBound
              ? convertToStringWithAtMostTwoDecimalPlaces(
                  b.upperBound,
                  tableColumnStyle
                )
              : undefined,
          titleBelow:
            i === 0 && b.upperBound !== extremes.minimum
              ? convertToStringWithAtMostTwoDecimalPlaces(
                  extremes.minimum,
                  tableColumnStyle
                )
              : undefined,
          color: convertColorArrayToCssString(b.colorArray)
        };
      })
    };
  }
  // Add a null color at the bottom (ie front of the array) if there are any null values
  if (tableColumn.values.indexOf(null) >= 0) {
    result.items.unshift({
      title: nullLabel,
      color: convertColorArrayToCssString(legendHelper._nullColorArray),
      spacingAbove: 8
    });
  }
  return result;
}

module.exports = LegendHelper;