Source: helpers/scalable.js

/**
 * Plugin registry of available functions that can be used in scalable layout directives.
 *
 * These "scale functions" are used during rendering to return output (eg color) based on input value
 *
 * @module LocusZoom_ScaleFunctions
 * @see {@link module:LocusZoom_DataLayers~ScalableParameter} for details on how scale functions are used by datalayers
 */

import * as d3 from 'd3';

/**
 * Basic conditional function to evaluate the value of the input field and return based on equality.
 * @alias module:LocusZoom_ScaleFunctions~if
 * @param {Object} parameters
 * @param {*} parameters.field_value The value against which to test the input value.
 * @param {*} parameters.then The value to return if the input value matches the field value
 * @param {*} parameters.else  The value to return if the input value does not match the field value. Optional. If not
 *   defined this scale function will return null (or value of null_value parameter, if defined) when input value fails
 *   to match field_value.
 * @param {*} value value
 */
const if_value = (parameters, value) => {
    if (typeof value == 'undefined' || parameters.field_value !== value) {
        if (typeof parameters.else != 'undefined') {
            return parameters.else;
        } else {
            return null;
        }
    } else {
        return parameters.then;
    }
};

/**
 * Function to sort numerical values into bins based on numerical break points. Will only operate on numbers and
 *   return null (or value of null_value parameter, if defined) if provided a non-numeric input value. Parameters:
 * @function numerical_bin
 * @param {Object} parameters
 * @param {Number[]} parameters.breaks  Array of numerical break points against which to evaluate the input value.
 *   Must be of equal length to values parameter. If the input value is greater than or equal to break n and less than
 *   or equal to break n+1 (or break n+1 doesn't exist) then returned value is the nth entry in the values parameter.
 * @param {Array} parameters.values  Array of values to return given evaluations against break points. Must be of
 *   equal length to breaks parameter. Each entry n represents the value to return if the input value is greater than
 *   or equal to break n and less than or equal to break n+1 (or break n+1 doesn't exist).
 * @param {*} parameters.null_value
 * @param {*} value value
 * @returns {*}
 */
const numerical_bin = (parameters, value) => {
    const breaks = parameters.breaks || [];
    const values = parameters.values || [];
    if (typeof value == 'undefined' || value === null || isNaN(+value)) {
        return (parameters.null_value ? parameters.null_value : null);
    }
    const threshold = breaks.reduce(function (prev, curr) {
        if (+value < prev || (+value >= prev && +value < curr)) {
            return prev;
        } else {
            return curr;
        }
    });
    return values[breaks.indexOf(threshold)];
};

/**
 * Function to sort values of any type into bins based on direct equality testing with a list of categories.
 *   Will return null if provided an input value that does not match to a listed category.
 * @function categorical_bin
 * @param {Object} parameters
 * @param {Array} parameters.categories  Array of values against which to evaluate the input value. Must be of equal
 *   length to values parameter. If the input value is equal to category n then returned value is the nth entry in the
 *   values parameter.
 * @param {Array} parameters.values  Array of values to return given evaluations against categories. Must be of equal
 *   length to categories parameter. Each entry n represents the value to return if the input value is equal to the nth
 *   value in the categories parameter.
 * @param {*} parameters.null_value  Value to return if the input value fails to match to any categories. Optional.
 */
const categorical_bin = (parameters, value) => {
    if (typeof value == 'undefined' || !parameters.categories.includes(value)) {
        return (parameters.null_value ? parameters.null_value : null);
    } else {
        return parameters.values[parameters.categories.indexOf(value)];
    }
};

/**
 * Cycle through a set of options, so that the each element in a set of data receives a value different than the
 *  element before it. For example: "use this palette of 10 colors to visually distinguish 100 adjacent items"
 * This is useful when ADJACENT items must be guaranteed to yield a different result, but it leads to unstable color
 *  choices if the user pans to a region with a different number/order of items. (the same item is assigned a different color)
 *
 *  See also: stable_choice.
 * @function ordinal_cycle
 *  @param {Object} parameters
 *  @param {Array} parameters.values A list of option values
 * @return {*}
 */
const ordinal_cycle = (parameters, value, index) => {
    const options = parameters.values;
    return options[index % options.length];
};

/**
 * A scale function that auto-chooses something (like color) from a preset scheme, and makes the same choice every
 * time given the same value, regardless of ordering or what other data is in the region
 *
 * This is useful when categories must be stable (same color, every time). But sometimes it will assign adjacent values
 *  the same color due to hash collisions.
 *
 * For performance reasons, this is memoized once per instance. Eg, each scalable color parameter has its own cache.
 *  This function is therefore slightly less amenable to layout mutations like "changing the options after scaling
 *  function is used", but this is not expected to be a common use case.
 *
 *  CAVEAT: Some datasets do not return true datum ids, but instead append synthetic ID fields ("item 1, item2"...)
 *    just to appease D3. This hash function only works if there is a meaningful, stable identifier in the data,
 *    like a category or gene name.
 *
 * @function stable_choice
 *
 * @param parameters
 * @param {Array} [parameters.values] A list of options to choose from
 * @param {Number} [parameters.max_cache_size=500] The maximum number of values to cache. This option is mostly used
 *  for unit testing, because stable choice is intended for datasets with a relatively limited number of
 *  discrete categories.
 * @param value
 * @param index
 */
let stable_choice = (parameters, value, index) => {
    // Each place the function gets used has its own parameters object. This function thus memoizes per usage
    //  ("association - point color - directive 1") rather than globally ("all properties/panels")
    const cache = parameters._cache = parameters._cache || new Map();
    const max_cache_size = parameters.max_cache_size || 500;

    if (cache.size >= max_cache_size) {
        // Prevent cache from growing out of control (eg as user moves between regions a lot)
        cache.clear();
    }
    if (cache.has(value)) {
        return cache.get(value);
    }

    // Simple JS hashcode implementation, from:
    //  https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript
    let hash = 0;
    value = String(value);
    for (let i = 0; i < value.length; i++) {
        let chr = value.charCodeAt(i);
        hash  = ((hash << 5) - hash) + chr;
        hash |= 0; // Convert to 32bit integer
    }
    // Convert signed 32 bit integer to be within the range of options allowed
    const options = parameters.values;
    const result = options[Math.abs(hash) % options.length];
    cache.set(value, result);
    return result;
};

/**
 * Function for continuous interpolation of numerical values along a gradient with arbitrarily many break points.
 * @function interpolate
 * @parameters {Object} parameters
 * @parameters {Number[]} parameters.breaks  Array of numerical break points against which to evaluate the input value.
 *   Must be of equal length to values parameter and contain at least two elements. Input value will be evaluated for
 *   relative position between two break points n and n+1 and the returned value will be interpolated at a relative
 *   position between values n and n+1.
 * @parameters {*[]} parameters.values  Array of values to interpolate and return given evaluations against break
 *   points. Must be of equal length to breaks parameter and contain at least two elements. Each entry n represents
 *   the value to return if the input value matches the nth entry in breaks exactly. Note that this scale function
 *   uses d3.interpolate to provide for effective interpolation of many different value types, including numbers,
 *   colors, shapes, etc.
 * @parameters {*} parameters.null_value
 */
const interpolate = (parameters, value) => {
    var breaks = parameters.breaks || [];
    var values = parameters.values || [];
    var nullval = (parameters.null_value ? parameters.null_value : null);
    if (breaks.length < 2 || breaks.length !== values.length) {
        return nullval;
    }
    if (typeof value == 'undefined' || value === null || isNaN(+value)) {
        return nullval;
    }
    if (+value <= parameters.breaks[0]) {
        return values[0];
    } else if (+value >= parameters.breaks[parameters.breaks.length - 1]) {
        return values[breaks.length - 1];
    } else {
        var upper_idx = null;
        breaks.forEach(function (brk, idx) {
            if (!idx) {
                return;
            }
            if (breaks[idx - 1] <= +value && breaks[idx] >= +value) {
                upper_idx = idx;
            }
        });
        if (upper_idx === null) {
            return nullval;
        }
        const normalized_input = (+value - breaks[upper_idx - 1]) / (breaks[upper_idx] - breaks[upper_idx - 1]);
        if (!isFinite(normalized_input)) {
            return nullval;
        }
        return d3.interpolate(values[upper_idx - 1], values[upper_idx])(normalized_input);
    }
};


/**
 * Calculate the effect direction based on beta, or the combination of beta and standard error.
 *   Typically used with phewas plots, to show point shape based on the beta and stderr_beta fields.
 *
 * @function effect_direction
 * @param parameters
 * @param parameters.'+' The value to return if the effect direction is positive
 * @param parameters.'-' The value to return if the effect direction is positive
 * @param parameters.beta_field The name of the field containing beta
 * @param parameters.stderr_beta_field The name of the field containing stderr_beta
 * @param {Object} input This function should receive the entire datum object, rather than one single field
 * @returns {null}
 */
function effect_direction(parameters, input) {
    if (input === undefined) {
        return null;
    }

    const { beta_field, stderr_beta_field, '+': plus_result = null, '-': neg_result = null } = parameters;

    if (!beta_field || !stderr_beta_field) {
        throw new Error(`effect_direction must specify how to find required 'beta' and 'stderr_beta' fields`);
    }

    const beta_val = input[beta_field];
    const se_val = input[stderr_beta_field];

    if (beta_val !== undefined) {
        if (se_val !== undefined) {
            if ((beta_val - 1.96 * se_val) > 0) {
                return plus_result;
            } else if ((beta_val + 1.96 * se_val) < 0) {
                return neg_result || null;
            }
        } else {
            if (beta_val > 0) {
                return plus_result;
            } else if (beta_val < 0) {
                return neg_result;
            }
        }
    }
    // Note: The original PheWeb implementation allowed odds ratio in place of beta/se. LZ core is a bit more rigid
    //   about expected data formats for layouts.
    return null;
}

export { categorical_bin, stable_choice, if_value, interpolate, numerical_bin, ordinal_cycle, effect_direction };