Source: Models/LegendHelper.js

'use strict';

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

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 simplestats = require('simple-statistics');

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: simplestats.quantile(numericalValues, (i + 1) / binCount),
                colorArray: getColorArrayFromColorGradient(colorGradient, i / (binCount - 1))
            });
        }
    } else if (method === 'ckmeans') {
        var clusters = simplestats.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;