aggregateDataset = (
  data,
  groupByFeature,
  aggregateFuncs,
  columnsToInclude = null
) => {
  const {columns} = data;
  const groupByColumn = columns[groupByFeature.tableFieldIndex - 1];
  // map from category values (e.g. 'SF') to their orders in `catValueArr`
  const catValueMap = {};
  // an array of category values (e.g. ['SF', 'LA', ...])
  const catValueArr = [];
  // an array of array of data ids, ids in the same subarray share the same category value
  const dataIdsByCategory = [];
  assert(
    groupByFeature.type === FEATURE_TYPE.CATEGORICAL ||
      groupByFeature.dataType == 'string',
    'groupByFeature for aggregation must be a non-continuous feature'
  );

  // get an array of array of dataIds grouped by hexId in this column
  // e,g [[0, 4, 5], [2, 8], [3, 6], [1, 7, 9]]
  groupByColumn.forEach((catVal, dataId) => {
    if (catValueMap[catVal] !== undefined) {
      dataIdsByCategory[catValueMap[catVal]].push(dataId);
    } else {
      dataIdsByCategory.push([dataId]);
      catValueMap[catVal] = dataIdsByCategory.length - 1;
      catValueArr.push(catVal);
    }
  });

  // aggregate other columns based on the grouping
  const datasetToAggregate = columnsToInclude
    ? gatherDataset(data, columnsToInclude)
    : data;
  const {columns: columnsToAgg, fields: fieldsToAgg} = datasetToAggregate;

  const getAggregateFuncs = colId => {
    const _aggregateFuncs = Array.isArray(aggregateFuncs)
      ? aggregateFuncs
      : aggregateFuncs[fieldsToAgg[colId].name];
    assert(
      Array.isArray(_aggregateFuncs),
      'aggregateFuncs must be an array of a map of arrays'
    );
    return _aggregateFuncs;
  };
  const resultColumns = columnsToAgg.reduce((acc, col, colId) => {
    const _aggregateFuncs = getAggregateFuncs(colId);
    const metricColsFromOneCol = _aggregateFuncs.map(metric => {
      const {func, name} = metric;
      assert(
        typeof func === 'function' &&
          (typeof name === 'function' || typeof name === 'string'),
        'aggregateFuncs must have `func` and `name` fields'
      );
      return dataIdsByCategory.map(dataIds => {
        const subCol = dataIds.map(dataId => col[dataId]);
        return func(subCol);
      });
    });
    return acc.concat(metricColsFromOneCol);
  }, []);

  // tableFieldIndex starts from 2; 1 is saved for `groupByFeature` column
  let fieldsLength = 1;
  const resultFields = fieldsToAgg.reduce((acc, field, i) => {
    const _aggregateFuncs = getAggregateFuncs(i);
    const metricFieldsFromOneField = _aggregateFuncs.map((metric, j) => {
      fieldsLength++;
      return {
        name:
          typeof metric.name === 'function'
            ? metric.name(field.name)
            : `${field.name}_${metric.name}`,
        tableFieldIndex: fieldsLength,
        // todo: in some cases data can be other types
        dataType: 'float',
      };
    });
    return acc.concat(metricFieldsFromOneField);
  }, []);

  // add groupby feature as a column
  resultColumns.unshift(catValueArr);
  resultFields.unshift({
    ...groupByFeature,
    tableFieldIndex: 1,
  });

  return {
    columns: resultColumns,
    fields: resultFields,
  };
}
aggregate dataset by one categorical feature
@param {Object} data contains {columns, fields}
@param {Object} groupByFeature one field definition, contains {tableFieldIndex, name, type, dataType}
@param {Array<Number>} columnsToInclude ids of columns to include in the output dataset (do not include the `groupByFeature` id)
@param {Array<Object>|Object} aggregateFuncs an array of functions indicating the types of metrics to compute out of the aggregation
can also be a map of array of functions, mapping from field name to metrics to compute out of that field
e.g. [{name: 'mean', func: mean}, {name: 'max', func: Math.max}], or
{
  feature1: [{name: 'mean', func: mean},
  feature2: [{name: 'max', func: Math.max}]
}
@returns dataset with {columns, fields}
@example
const data = {
  fields: [{name: 'feature1', ...}, {name: 'feature2', ...}, {name: 'feature3', ...}]
  columns: [
    // values of feature1
    [0, 0, 0, 0, 1, 1, 1, 0],
    // values of feature2
    [0, 1, 2, 3, 4, 5, 6, 7],
    // values of feature3
    [8, 9, 10, 11, 12, 13, 14, 15],
  ]
};
aggregateDataset(
  data,
  {name: 'feature1', ...},
  [1, 2],
  [{name: 'max', func: arr => Math.max(...arr)}]
)
// returns:
{
  fields: [{name: 'feature1', ...}, {name: 'feature2_max', ...}, {name: 'feature3_max', ...}]
  columns: [
    // unique values of feature1
    [0, 1],
    // max values of feature2 aggregated by values of feature1, i.e. [Math.max(0, 1, 2, 3, 7), Math.max(4, 5, 6)]
    [7, 6],
    // max values of feature3 aggregated by values of feature1, i.e. [Math.max(8, 9, 10, 11, 15), Math.max(12, 13, 14)]
    [15, 14],
  ]
}

aggregateDataset(
  data,
  {name: 'feature1', ...},
  [1, 2],
  {
    feature2: [{name: 'max', func: arr => Math.max(...arr)}],
    feature3: [{name: 'mean', func: mean}],
  }
)
// returns:
{
  fields: [{name: 'feature1', ...}, {name: 'feature2_max', ...}]
  columns: [
    // unique values of feature1
    [0, 1],
    // max values of feature2 aggregated by values of feature1, i.e. [Math.max(0, 1, 2, 3, 7), Math.max(4, 5, 6)]
    [7, 6],
    // means of feature3 aggregated by values of feature1, i.e. [mena(8, 9, 10, 11, 15), min(12, 13, 14)]
    [10.6, 13],
  ]
}