components/histogram/src/utils.js

/**
* @module @svizzle/histogram/utils
*/

import * as _ from 'lamb';
import {extent, pairs} from 'd3-array';
import {
	arrayMaxWith,
	arrayMinWith,
	getFirstAndLast,
	getValues,
	inclusiveRange,
	isIterableNotEmpty,
	isNotNil,
	makeIsWithinRange,
} from '@svizzle/utils';

/* binning */

/**
 * A binning function that returns an exact amount of bins.
 *
 * @function
 * @arg {array} items - items to be binned
 * @arg {number} amount - desired amount of bins
 * @arg {function} [accessor=_.identity] - item accessor
 * @arg {(number[]|null)} [maxExtent=null] - the desired output extent
 * @return {array} bins - {range, values}[]
 *
 * @example
> const items = [1, 2, 6, 7, 8, 14, 20];
> exactAmountBins({array: items, amount: 3});
[
	{range: [1, 8], values: [1, 2, 6, 7, 8]},
	{range: [8, 15], values: [14]},
	{range: [15, 22], values: [20]}
]
> exactAmountBins({
	array,
	amount: 3,
	maxExtent: [2, 15]
})
[
	{range: [2, 7], values: [2, 6, 7]},
	{range: [7, 12], values: [8]},
	{range: [12, 17], values: [14]}
]
> const items = [{a: 1}, {a: 2}, {a: 6}, {a: 7}, {a: 8}, {a: 12}, {a: 14}, {a: 20}];
> exactAmountBins({
	array,
	amount: 3,
	accessor: _.getKey('a'),
	maxExtent: [2, 14]
});
[
	{range: [2, 6], values: [{a: 2}, {a: 6}]},
	{range: [6, 10], values: [{a: 7}, {a: 8}]},
	{range: [10, 14], values: [{a: 12}, {a: 14}]}
]
 *
 * @since 0.1.0
 */
export const exactAmountBins = ({
	array,
	amount,
	accessor = _.identity,
	maxExtent = null
}) => {
	const activeRange = maxExtent
		? _.sort(maxExtent)
		: extent(array, accessor);
	const [min, max] = activeRange;

	if (min === max) {
		return [{values: array}]
	}

	const integerMin = Math.floor(min);
	const step = Math.ceil((max - integerMin) / amount);
	const integerMax = integerMin + step * amount;
	const ranges = pairs(inclusiveRange([integerMin, integerMax, step]));

	// TODO svizzle/utils
	const findRangeIndex = _.adapter(
		_.map(ranges, (range, index) => {
			const predicate = _.pipe([
				accessor,
				_.allOf([
					makeIsWithinRange(activeRange),
					makeIsWithinRange(range),
				])
			]);

			return value => predicate(value) ? index : undefined
		})
	);

	return _.reduce(array,
		(acc, item) => {
			const index = findRangeIndex(item);
			isNotNil(index) && acc[index].values.push(item)
			return acc;
		},
		_.map(ranges, range => ({range, values: []}))
	);
}

/* bins getters and functions */

/**
 * Returns true if bins are valid, meaning:
 * - there is at least one bin object
 * - they all have a `range` key
 * - `range` is not `null` or `undefined`
 *
 * @function
 * @arg {array} bins
 * @return {array} bins - {range, values}[]
 *
 * @example
> areValidBins([])
false

> areValidBins([
	{values: [{a: 2}, {a: 6}]},
	{range: [6, 10], values: [{a: 7}, {a: 8}]},
	{range: [10, 14], values: [{a: 12}, {a: 14}]}
])
false

> areValidBins([
	{range: null, values: [{a: 2}, {a: 6}]},
	{range: [6, 10], values: [{a: 7}, {a: 8}]},
	{range: [10, 14], values: [{a: 12}, {a: 14}]}
])
false

> areValidBins([
	{range: [2, 6], values: [{a: 2}, {a: 6}]},
	{range: [6, 10], values: [{a: 7}, {a: 8}]},
	{range: [10, 14], values: [{a: 12}, {a: 14}]}
])
true
 *
 * @since 0.1.0
 */
export const areValidBins = _.allOf([
	isIterableNotEmpty,
	_.every(_.allOf([
		_.hasKey('range'),
		_.pipe([_.getKey('range'), isNotNil])
	]))
]);

/**
 * Return all the values in the provided bins
 *
 * @function
 * @arg {array} bins
 * @return {any[]} values
 *
 * @example
> getBinsItems([
	{range: [2, 6], values: [{a: 2}, {a: 6}]},
	{range: [6, 10], values: [{a: 7}, {a: 8}]},
	{range: [10, 14], values: [{a: 12}, {a: 14}]}
])
[{a: 2}, {a: 6}, {a: 7}, {a: 8}, {a: 12}, {a: 14}]
 *
 * @since 0.1.0
 */
export const getBinsItems = _.pipe([
	_.mapWith(getValues),
	_.flatten,
]);

export const getValuesLength = _.getPath('values.length');

/**
 * Return the length of the longest bin
 *
 * @function
 * @arg {array} bins
 * @return {number} max - length of the longest bin
 *
 * @example
> getBinsMin([
	{range: [2, 7], values: [2, 6, 7]},
	{range: [7, 12], values: [8]},
	{range: [12, 17], values: [14]}
])
3
 *
 * @since 0.1.0
 */
export const getBinsMax = arrayMaxWith(getValuesLength);

/**
 * Return the length of the shortest bin
 *
 * @function
 * @arg {array} bins
 * @return {number} min - length of the shortest bin
 *
 * @example
> getBinsMin([
	{range: [2, 7], values: [2, 6, 7]},
	{range: [7, 12], values: [8]},
	{range: [12, 17], values: [14]}
])
1
 *
 * @since 0.1.0
 */
export const getBinsMin = arrayMinWith(getValuesLength);

/**
 * Return the extent of the provided bins
 *
 * @function
 * @arg {array} bins
 * @return {array} extent
 *
 * @example
> getBinsExtent([
	{range: [2, 7], values: [2, 6, 7]},
	{range: [7, 12], values: [8]},
	{range: [12, 17], values: [14]}
])
[1, 3]
 *
 * @since 0.1.0
 */
export const getBinsExtent = _.collect([getBinsMin, getBinsMax]);

/**
 * Return `true` if the `values` property of the provided bin is not empty
 *
 * @function
 * @arg {object} bin - {range, values}
 * @return {boolean}
 *
 * @example
> isNonEmptyBin({range: [-8, -3], values: []})
false
> isNonEmptyBin({range: [2, 7], values: [2, 6, 7]})
true
 *
 * @since 0.1.0
 */
export const isNonEmptyBin = _.pipe([getValues, isIterableNotEmpty]);

/**
 * Return the index of the first bin with non-empty `values`
 *
 * @function
 * @arg {array} bins - {range, values}[]
 * @return {number}
 *
 * @example
> findFirstNonEmptyBinIndex([
	{range: [-8, -3], values: []},
	{range: [-3, 2], values: []},
	{range: [2, 7], values: [2, 6, 7]},
	{range: [7, 12], values: [8]},
	{range: [12, 17], values: [14]},
	{range: [17, 22], values: []},
])
2
 *
 * @since 0.1.0
 */
export const findFirstNonEmptyBinIndex = _.findIndexWhere(isNonEmptyBin);

/**
 * Return the index of the last bin with non-empty `values`
 *
 * @function
 * @arg {array} bins - {range, values}[]
 * @return {number}
 *
 * @example
> findLastNonEmptyBinIndex([
	{range: [-8, -3], values: []},
	{range: [-3, 2], values: []},
	{range: [2, 7], values: [2, 6, 7]},
	{range: [7, 12], values: [8]},
	{range: [12, 17], values: [14]},
	{range: [17, 22], values: []},
])
4
 *
 * @since 0.1.0
 */
export const findLastNonEmptyBinIndex = _.findLastIndexWhere(isNonEmptyBin);

/**
 * Return an object containing:
 * - a copy of the provided bins without the trailing bins with no values
 * - `start` and `end` of the trim
 *
 * @function
 * @arg {array} bins
 * @return {object} object - {bins, end, start}
 *
 * @example
> getTrimmedBinsStats([
	{range: [-8, -3], values: []},
	{range: [-3, 2], values: []},
	{range: [2, 7], values: [2, 6, 7]},
	{range: [7, 12], values: [8]},
	{range: [12, 17], values: [14]},
	{range: [17, 22], values: []},
])
{
	bins: [
		{range: [2, 7], values: [2, 6, 7]},
		{range: [7, 12], values: [8]},
		{range: [12, 17], values: [14]},
	],
	end: 4,
	start: 2
}
> getTrimmedBinsStats([
	{range: [2, 7], values: [2, 6, 7]},
	{range: [7, 12], values: [8]},
	{range: [12, 17], values: [14]},
	{range: [17, 22], values: []},
])
{
	bins: [
		{range: [2, 7], values: [2, 6, 7]},
		{range: [7, 12], values: [8]},
		{range: [12, 17], values: [14]},
	],
	end: 2,
	start: 0
}
 *
 * @since 0.1.0
 */
export const getTrimmedBinsStats = bins => {
	const start = findFirstNonEmptyBinIndex(bins);
	const end = findLastNonEmptyBinIndex(bins);

	return {
		bins: _.slice(bins, start, end + 1),
		end,
		start
	};
};

/* ticks */

/**
 * Return the ticks for the provided bins
 *
 * @function
 * @arg {array} bins
 * @return {number[]} ticks
 *
 * @example
> getBinsTicks([
	{range: [-8, -3], values: []},
	{range: [-3, 2], values: []},
	{range: [2, 7], values: [2, 6, 7]},
	{range: [7, 12], values: []},
	{range: [12, 17], values: []},
	{range: [17, 22], values: [18, 19, 20]},
	{range: [22, 27], values: [24, 25]},
	{range: [27, 32], values: []},
])
[-8, -3, 2, 7, 12, 17, 22, 27, 32]
 *
 * @since 0.1.0
 */
export const getBinsTicks = _.pipe([
	_.mapWith(_.getKey('range')),
	_.flatten,
	_.uniques,
	_.sortWith([])
]);

/**
 * Return the extent of all ticks for the provided bins
 *
 * @function
 * @arg {array} bins
 * @return {number[]} ticks extent
 *
 * @example
> getBinsTicksExtent([
	{range: [-8, -3], values: []},
	{range: [-3, 2], values: []},
	{range: [2, 7], values: [2, 6, 7]},
	{range: [7, 12], values: []},
	{range: [12, 17], values: []},
	{range: [17, 22], values: [18, 19, 20]},
	{range: [22, 27], values: [24, 25]},
	{range: [27, 32], values: []},
])
[-8, 32]
 *
 * @since 0.4.0
 */
export const getBinsTicksExtent = _.pipe([getBinsTicks, getFirstAndLast]);

/**
 * Return the ticks for the provided bins using the non-empty ones
 *
 * @function
 * @arg {array} bins
 * @return {number[]} ticks
 *
 * @example
> getNonEmptyBinsTicks([
	{range: [-8, -3], values: []},
	{range: [-3, 2], values: []},
	{range: [2, 7], values: [2, 6, 7]},
	{range: [7, 12], values: []},
	{range: [12, 17], values: []},
	{range: [17, 22], values: [18, 19, 20]},
	{range: [22, 27], values: [24, 25]},
	{range: [27, 32], values: []},
])
[2, 7, 17, 22, 27]
 *
 * @since 0.1.0
 */
export const getNonEmptyBinsTicks = _.pipe([
	_.filterWith(getValuesLength),
	getBinsTicks
]);