/**
* @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
]);