convert.js

/**
 *
 * @class Utility class that provides methods to convert and extract data from Arrays and Maps.
 * @hideconstructor
 *
 * @copyright (c) 2021 TLF Research Ltd.
 *
 */
function Convert() { }

/**
 * Forces the result of an <code>arrayToMap</code> call so every matching key always points to an array.
 *
 * @memberof Convert
 * @constant {bool} FORCE_ARRAY
 */
Convert.FORCE_ARRAY = true;

/**
 * Given an array of objects and the name of a property, returns a map whose keys are the values of the given property or properties.
 * Each key points to the matching object element, unless the property value matches more than one element.
 * In this case its key will map to an array of all the matching elements.
 *
 * If <code>forceArray</code> is <code>true</code> then the key will *always* point to an array, even if only one element matches.
 *
 * The original array remains unchanged.
 *
 * @example
 *
 * const test = [
 *    {"foo": 100, "bar": "One", "baz": null},
 *    {"foo": 200, "bar": "Two", "baz": "baz2"},
 *    {"foo": 300, "bar": "Three", "baz": null},
 *    {"foo": 400, "bar": "Four", "baz": "baz4"},
 *    {"foo": 500, "bar": "Five", "baz": "baz5"}
 * ];
 *
 * @example
 * const m1 = Convert.arrayToMap(test, "bar");
 * // m1 is:
 * // {
 * //    Five: {foo: 500, bar: "Five", baz: "baz5"},
 * //    Four: {foo: 400, bar: "Four", baz: "baz4"},
 * //    One: {foo: 100, bar: "One", baz: null},
 * //    Three: {foo: 300, bar: "Three", baz: null},
 * //    Two: {foo: 200, bar: "Two", baz: "baz2"}
 * // }
 *
 * @example
 * const m2 = Convert.arrayToMap(test, "baz");
 * // m2 is:
 * // {
 * //    baz2: {foo: 200, bar: "Two", baz: "baz2"},
 * //    baz4: {foo: 400, bar: "Four", baz: "baz4"},
 * //    baz5: {foo: 500, bar: "Five", baz: "baz5"},
 * //    null: [
 * //       {foo: 100, bar: "One", baz: null}
 * //       {foo: 300, bar: "Three", baz: null}
 * //    ]
 * // }
 *
 * @example
 * const m3 = Convert.arrayToMap(test, "baz", Convert.FORCE_ARRAY);
 * // m3 is:
 * // {
 * //    baz2: [{foo: 200, bar: "Two", baz: "baz2"}],
 * //    baz4: [{foo: 400, bar: "Four", baz: "baz4"}],
 * //    baz5: [{foo: 500, bar: "Five", baz: "baz5"}],
 * //    null: [
 * //       {foo: 100, bar: "One", baz: null},
 * //       {foo: 300, bar: "Three", baz: null}
 * //    ]
 * // }
 * @param {array} arr Array to inspect
 * @param {string} prop Property to extract.
 * @param {bool} [forceArray=false] When true the map values are always returned as arrays.
 *
 * @returns {Object}
 */
Convert.arrayToMap = function (arr, prop, forceArray) {
  return arr.reduce(function (acc, obj) {
    const clone = JSON.parse(JSON.stringify(obj));

    if (typeof acc[obj[prop]] !== "undefined") {
      // Multiple-values present so force result to an array (unless it's already an array)
      if (!Array.isArray(acc[obj[prop]])) {
        acc[obj[prop]] = [acc[obj[prop]]];
      }
      acc[obj[prop]].push(clone);
    } else {
      acc[obj[[prop]]] = forceArray ? [clone] : clone;
    }

    return acc;
  }, {});
};

/**
 * Given an array of strings, returns a map whose keys are the values of the array, with each key pointing to the same given value.
 * <code>val</code> can be a simple scaler, array, map etc.
 *
 * @example
 * const test = ["foo", "bar", "baz"];
 * const m = Convert.simpleArrayToMap(test, {val: 1});
 * // m is:
 * // {
 * //  "foo": {val: 1},
 * //  "bar": {val: 1},
 * //  "baz": {val: 1}
 * // }
 *
 * @param {array} arr Array to inspect
 * @param {*} val Value to assign to every map element
 *
 * @returns {Object}
 */
Convert.simpleArrayToMap = function (arr, val) {
  return arr.reduce(function (acc, string) {
    acc[string] = val;
    return acc;
  }, {});
};

/**
 * Extracts one or more properties from an array of objects and returns them as an array.
 * If only a single property is given the return value is an array of scalars, otherwise it's
 * an array of objects containing the extracted properties.
 *
 * @example
 * const test = [
 *    {"foo": 100, "bar": "One", "baz": null},
 *    {"foo": 200, "bar": "Two", "baz": "baz2"},
 *    {"foo": 300, "bar": "Three", "baz": null},
 *    {"foo": 400, "bar": "Four", "baz": "baz4"},
 *    {"foo": 500, "bar": "Five", "baz": "baz5"}
 * ];
 *
 * @example
 * const val1 = Convert.extractFromArray(test, "foo");
 * // val1 is:
 * // [ 100, 200, 300, 400, 500 ]
 *
 * @example
 * const val2 = Convert.extractFromArray(test, "foo", el => el.foo < 400);
 * // val2 is:
 * // [ 100, 200, 300 ]
 *
 * @example
 * const val3 = Convert.extractFromArray(test, "bar", el => el.foo < 400);
 * // val3 is:
 * // [ "One", "Two", "Three" ]
 *
 * @example
 * const val3 = Convert.extractFromArray(test, "bar", el => el.foo < 400, , val => `Value is ${val}`);
 * // val3 is:
 * // [ "Value is One", "Value is Two", "Value is Three" ]
 *
 * @example
 * const val4 = Convert.extractFromArray(test, ["foo", "bar"], el => el.foo < 400, , val => `Value is ${val}`);
 * // val4 is:
 * // [
 * //    {foo: "Value is 100", bar: "Value is One"},
 * //    {foo: "Value is 200", bar: "Value is Two"},
 * //    {foo: "Value is 300", bar: "Value is Three"}
 * // ]
 *
 * @param {array} arr Array to process
 * @param {string|array} props One or more properties to extract
 * @param {function} [predicate] Filter method that is called with the following parameters: element (object), property_name (string) and results_so_far (array).
 * It should return true if the property's value is to be included in the extract.
 * @param {function} [transform] Method that will be applied to each returned property to transform the returned value.
 * The method is called with the extracted value and its row.
 * It should return the new property value.
 * @param {function} [rename] Method that will be applied to each returned property to remap each property's name (for objects only).
 * The method is called with the name of each property.
 * It should return the new property name (or null to keep the existing name).
 *
 * @returns {Array}
 */
Convert.extractFromArray = function (arr, props, predicate, transform, rename) {
  const haveArray = Array.isArray(props);

  // By default extract from all objects
  predicate = predicate || (() => true);

  // By default return results verbatim
  transform = transform || ((val) => val);

  // By default return object keys verbatim
  rename = rename || (() => null);

  let ret = [];

  if (haveArray) {
    arr.forEach(function (obj) {
      const extract = {};
      props.forEach(function (prop) {
        if (predicate(obj, prop, ret)) {
          const newVal = transform(obj[prop], obj, prop);
          const newProp = rename(prop) || prop;
          extract[newProp] = newVal;
        }
      });
      if (Object.keys(extract).length !== 0) {
        ret.push(extract);
      }
    });
  } else {
    arr.forEach(function (obj) {
      if (predicate(obj, props, ret)) {
        ret.push(transform(obj[props], obj));
      }
    });
  }

  return ret;
};

/**
 * Extracts a single numeric property from an array of objects and converts them into an array of proportional values in the interval [0, 1].
 * If a predicate function is used the proportions are calculated based only on those values that pass the filter.
 *
 * @example
 * const test = [
 *    {"foo": 100, "bar": "One", "baz": null},
 *    {"foo": 200, "bar": "Two", "baz": "baz2"},
 *    {"foo": 300, "bar": "Three", "baz": null},
 *    {"foo": 400, "bar": "Four", "baz": "baz4"},
 *    {"foo": 500, "bar": "Five", "baz": "baz5"}
 * ];
 *
 * @example
 * const val1 = Convert.extractFromArrayAsProportion(test, "foo");
 * // val1 is:
 * // [ 0.06666666666666667, 0.13333333333333333, 0.2, 0.26666666666666666, 0.3333333333333333 ]
 * // i.e. each value divided by the total (which is 1500)
 *
 * @example
 * // Convert a subset of the raw values to percentages
 * const val2 = Convert.extractFromArrayAsProportion(test, "foo", el => el.foo < 400, , val => val * 100);
 * // val2 is:
 * // [ 16.666666666666668, 33.333333333333336, 50 ]
 * // i.e. each value divided by the total (which is 600) multiplied by 100
 *
 * @param {array} arr Array to process
 * @param {string} prop Numeric property to extract
 * @param {function} [predicate] Filter method that should true if the property is to be included in the extract
 * @param {function} [transform] Method that will be applied to each proportion value
 *
 * @returns {Array}
 */
Convert.extractFromArrayAsProportion = function (arr, prop, predicate, transform) {
  if (Array.isArray(prop)) {
    console.error("Array properties are not supported");
    return;
  }

  // By default return results verbatim
  transform = transform || ((val) => val);

  const raw = Convert.extractFromArray(arr, prop, predicate);
  const total = raw.reduce((acc, x) => (acc += x), 0);

  return raw.map((x) => transform(x) / total);
};

/**
 * Converts the given numeric property in an array of objects into an array of proportional values in the interval [0, 1].
 * The given array is modified in-place.
 *
 * @example
 * const test = [
 *    {"foo": 100, "bar": "One", "baz": null},
 *    {"foo": 200, "bar": "Two", "baz": "baz2"},
 *    {"foo": 300, "bar": "Three", "baz": null},
 *    {"foo": 400, "bar": "Four", "baz": "baz4"},
 *    {"foo": 500, "bar": "Five", "baz": "baz5"}
 * ];
 *
 * @example
 * Convert.toProportion(test, "foo", "newfoo", v => v * 100);
 * // test is:
 * // [
 * //    {foo: 100, bar: "One", baz: null, newfoo: 6.666666666666667}
 * //    {foo: 200, bar: "Two", baz: "baz2", newfoo: 13.333333333333334}
 * //    {foo: 300, bar: "Three", baz: null, newfoo: 20}
 * //    {foo: 400, bar: "Four", baz: "baz4", newfoo: 26.666666666666668}
 * //    {foo: 500, bar: "Five", baz: "baz5", newfoo: 33.33333333333333}
 * // ]
 * // i.e. each value divided by the total (which is 1500) * 100
 *
 * @param {array} arr Array to process
 * @param {string} prop Original numeric property to extract
 * @param {string} [newprop=prop] Name of the new property to contain the proportional values.
 * Defaults to the same as the original which means the original values will be overwritten by the proportions.
 * @param {function} [transform] Method that will be applied to each proportion value
 *
 */
Convert.toProportion = function (arr, prop, newprop, transform) {
  if (Array.isArray(prop)) {
    console.error("Array properties are not supported");
    return;
  }

  // Default to replacing property
  newprop = newprop || prop;

  // By default return results verbatim
  transform = transform || ((x) => x);

  const raw = Convert.extractFromArray(arr, prop);
  const total = raw.reduce((acc, x) => (acc += x), 0);

  arr.forEach((val, idx) => {
    arr[idx][newprop] = transform(arr[idx][prop] / total);
  });
};

/**
 * ArrayUnique is a predicate function that can be used in the <code>extractFromArray</code> or <code>extractFromArrayAsProportion</code> methods to return only unique values.
 *
 * @example
 * const test = [
 *    {"foo": 100, "bar": "One"},
 *    {"foo": 200, "bar": "Two"},
 *    {"foo": 300, "bar": "One"},
 *    {"foo": 400, "bar": "Four"},
 *    {"foo": 500, "bar": "Two"}
 * ];
 *
 * @example
 * const val1 = Convert.extractFromArray(test, "bar", Convert.ArrayUnique);
 * // val1 is:
 * // [ "One", "Two", "Four" ]
 *
 * @return {boolean}
 */
Convert.ArrayUnique = function (obj, prop, ret) {
  const haveObjectArray = ret && ret.length && typeof ret[0] === "object";
  if (!haveObjectArray) {
    return ret.indexOf(obj[prop]) === -1;
  }

  return !ret.some((el) => el[prop] == obj[prop]);
};

/**
 * Extracts one or more properties from an map of objects and returns them as an object array.
 *
 * @example
 * const test = {
 *    obj1: { foo: 1, name: "Name 1"}",
 *    obj2: { foo: 2, name: "Name 2"}",
 *    obj3: { foo: 3, name: "Name 3"}",
 *    obj4: { foo: 4, name: "Name 4"}",
 * };
 *
 * @example
 * const r1 = Convert.extractFromMap(test, "name");
 * // r1 is ["Name 1", "Name 2", "Name 3", "Name 4"]
 *
 * @example
 * const r2 = Convert.extractFromMap(test, "name");
 * // r2 is ["obj1", "obj2", "obj3", "obj4"]
 *
 * @param {object} map Map to process
 * @param {string|array} props One or more properties to extract (the special value <code>__key</code> returns the map key)
 * @param {function} [predicate] Filter method that returns true if the map entry is to be included
 * @param {function} [transform] Method that will be applied to each returned property
 *
 * @returns {Array} If props is a string then the returned array is an array of strings, else it's an array of objects.
 */
Convert.extractFromMap = function (map, props, predicate, transform) {
  const propsIsArray = Array.isArray(props);

  // By default return all map entries
  predicate = predicate || (() => true);

  // By default return results verbatim
  transform = transform || ((val) => val);

  let ret = [];
  Object.keys(map).forEach(function (key) {
    if (predicate(key, map[key])) {
      if (propsIsArray) {
        const obj = {};
        props.forEach(function (prop) {
          obj[prop] = prop === "__key" ? key : transform(map[key][prop]);
        });
        ret.push(obj);
      } else {
        ret.push(transform(map[key][props]));
      }
    }
  });

  return ret;
};

/**
 * Returns the ordinal suffix for the given numeric value.
 *
 * @example
 * const ord1 = Convert.toOrdinal(1);
 * const ord2 = Convert.toOrdinal(2);
 * const ord3 = Convert.toOrdinal(12);
 * // ord1 is 1st
 * // ord2 is 2nd
 * // ord3 is 12th
 *
 * @param {number} i Numer to be converted
 *
 * @return {string} Ordinal
 *
 */
Convert.toOrdinal = function (i) {
  const j = i % 10;
  const k = i % 100;

  if (j === 1 && k !== 11) {
    return i + "st";
  }

  if (j === 2 && k !== 12) {
    return i + "nd";
  }

  if (j === 3 && k !== 13) {
    return i + "rd";
  }

  return i + "th";
};