mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
rename reducer to statsCalculator
This commit is contained in:
parent
2969507784
commit
a0fa5698e0
@ -3,45 +3,45 @@ import React, { PureComponent } from 'react';
|
|||||||
import { storiesOf } from '@storybook/react';
|
import { storiesOf } from '@storybook/react';
|
||||||
import { action } from '@storybook/addon-actions';
|
import { action } from '@storybook/addon-actions';
|
||||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||||
import { TableReducePicker } from './TableReducePicker';
|
import { StatsPicker } from './StatsPicker';
|
||||||
import { text, boolean } from '@storybook/addon-knobs';
|
import { text, boolean } from '@storybook/addon-knobs';
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
reducers: string[];
|
stats: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WrapperWithState extends PureComponent<any, State> {
|
export class WrapperWithState extends PureComponent<any, State> {
|
||||||
constructor(props: any) {
|
constructor(props: any) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
reducers: this.toReducersArray(props.initialReducers),
|
stats: this.toStatsArray(props.initialReducers),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
toReducersArray = (txt: string): string[] => {
|
toStatsArray = (txt: string): string[] => {
|
||||||
return txt.split(',').map(v => v.trim());
|
return txt.split(',').map(v => v.trim());
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidUpdate(prevProps: any) {
|
componentDidUpdate(prevProps: any) {
|
||||||
const { initialReducers } = this.props;
|
const { initialReducers } = this.props;
|
||||||
if (initialReducers !== prevProps.initialReducers) {
|
if (initialReducers !== prevProps.initialReducers) {
|
||||||
this.setState({ reducers: this.toReducersArray(initialReducers) });
|
this.setState({ stats: this.toStatsArray(initialReducers) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { placeholder, defaultReducer, allowMultiple } = this.props;
|
const { placeholder, defaultStat, allowMultiple } = this.props;
|
||||||
const { reducers } = this.state;
|
const { stats } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableReducePicker
|
<StatsPicker
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
defaultReducer={defaultReducer}
|
defaultStat={defaultStat}
|
||||||
allowMultiple={allowMultiple}
|
allowMultiple={allowMultiple}
|
||||||
reducers={reducers}
|
stats={stats}
|
||||||
onChange={(reducers: string[]) => {
|
onChange={(stats: string[]) => {
|
||||||
action('Picked:')(reducers);
|
action('Picked:')(stats);
|
||||||
this.setState({ reducers });
|
this.setState({ stats });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@ -52,16 +52,16 @@ const story = storiesOf('UI/TableReducePicker', module);
|
|||||||
story.addDecorator(withCenteredStory);
|
story.addDecorator(withCenteredStory);
|
||||||
story.add('picker', () => {
|
story.add('picker', () => {
|
||||||
const placeholder = text('Placeholder Text', '');
|
const placeholder = text('Placeholder Text', '');
|
||||||
const defaultReducer = text('Default Reducer', '');
|
const defaultStat = text('Default Stat', '');
|
||||||
const allowMultiple = boolean('Allow Multiple', false);
|
const allowMultiple = boolean('Allow Multiple', false);
|
||||||
const initialReducers = text('Initial Reducers', '');
|
const initialStats = text('Initial Stats', '');
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<WrapperWithState
|
<WrapperWithState
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
defaultReducer={defaultReducer}
|
defaultStat={defaultStat}
|
||||||
allowMultiple={allowMultiple}
|
allowMultiple={allowMultiple}
|
||||||
initialReducers={initialReducers}
|
initialStats={initialStats}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
@ -4,19 +4,19 @@ import isArray from 'lodash/isArray';
|
|||||||
|
|
||||||
import { Select } from '../index';
|
import { Select } from '../index';
|
||||||
|
|
||||||
import { getTableReducers } from '../../utils/tableReducer';
|
import { getStatsCalculators } from '../../utils/statsCalculator';
|
||||||
import { SelectOptionItem } from '../Select/Select';
|
import { SelectOptionItem } from '../Select/Select';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onChange: (reducers: string[]) => void;
|
onChange: (stats: string[]) => void;
|
||||||
reducers: string[];
|
stats: string[];
|
||||||
width?: number;
|
width?: number;
|
||||||
allowMultiple?: boolean;
|
allowMultiple?: boolean;
|
||||||
defaultReducer?: string;
|
defaultStat?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TableReducePicker extends PureComponent<Props> {
|
export class StatsPicker extends PureComponent<Props> {
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
width: 12,
|
width: 12,
|
||||||
allowMultiple: false,
|
allowMultiple: false,
|
||||||
@ -31,25 +31,25 @@ export class TableReducePicker extends PureComponent<Props> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
checkInput = () => {
|
checkInput = () => {
|
||||||
const { reducers, allowMultiple, defaultReducer, onChange } = this.props;
|
const { stats, allowMultiple, defaultStat, onChange } = this.props;
|
||||||
|
|
||||||
// Check that the selected reducers are all real
|
// Check that the selected reducers are all real
|
||||||
const notFound: string[] = [];
|
const notFound: string[] = [];
|
||||||
const current = getTableReducers(reducers, notFound);
|
const current = getStatsCalculators(stats, notFound);
|
||||||
if (notFound.length > 0) {
|
if (notFound.length > 0) {
|
||||||
console.warn('Unknown reducers', notFound, reducers);
|
console.warn('Unknown reducers', notFound, stats);
|
||||||
onChange(current.map(reducer => reducer.value));
|
onChange(current.map(reducer => reducer.value));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make sure there is only one
|
// Make sure there is only one
|
||||||
if (!allowMultiple && reducers.length > 1) {
|
if (!allowMultiple && stats.length > 1) {
|
||||||
console.warn('Removing extra reducers', reducers);
|
console.warn('Removing extra stat', stats);
|
||||||
onChange([reducers[0]]);
|
onChange([stats[0]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the reducer from callback
|
// Set the reducer from callback
|
||||||
if (defaultReducer && reducers.length < 1) {
|
if (defaultStat && stats.length < 1) {
|
||||||
onChange([defaultReducer]);
|
onChange([defaultStat]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -63,17 +63,17 @@ export class TableReducePicker extends PureComponent<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { width, reducers, allowMultiple, defaultReducer, placeholder } = this.props;
|
const { width, stats, allowMultiple, defaultStat, placeholder } = this.props;
|
||||||
const current = getTableReducers(reducers);
|
const current = getStatsCalculators(stats);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
width={width}
|
width={width}
|
||||||
value={current}
|
value={current}
|
||||||
isClearable={!defaultReducer}
|
isClearable={!defaultStat}
|
||||||
isMulti={allowMultiple}
|
isMulti={allowMultiple}
|
||||||
isSearchable={true}
|
isSearchable={true}
|
||||||
options={getTableReducers()}
|
options={getStatsCalculators()}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onChange={this.onSelectionChange}
|
onChange={this.onSelectionChange}
|
||||||
/>
|
/>
|
73
packages/grafana-ui/src/utils/statsCalculator.test.ts
Normal file
73
packages/grafana-ui/src/utils/statsCalculator.test.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { parseCSV } from './processTableData';
|
||||||
|
import { getStatsCalculators, StatID, calculateStats } from './statsCalculator';
|
||||||
|
|
||||||
|
describe('Stats Calculators', () => {
|
||||||
|
const basicTable = parseCSV('a,b,c\n10,20,30\n20,30,40');
|
||||||
|
|
||||||
|
it('should load all standard stats', () => {
|
||||||
|
const names = [
|
||||||
|
StatID.sum,
|
||||||
|
StatID.max,
|
||||||
|
StatID.min,
|
||||||
|
StatID.logmin,
|
||||||
|
StatID.mean,
|
||||||
|
StatID.last,
|
||||||
|
StatID.first,
|
||||||
|
StatID.count,
|
||||||
|
StatID.range,
|
||||||
|
StatID.diff,
|
||||||
|
StatID.step,
|
||||||
|
StatID.delta,
|
||||||
|
// StatID.allIsZero,
|
||||||
|
// StatID.allIsNull,
|
||||||
|
];
|
||||||
|
const notFound: string[] = [];
|
||||||
|
const stats = getStatsCalculators(names, notFound);
|
||||||
|
stats.forEach((stat, index) => {
|
||||||
|
expect(stat ? stat.value : '<missing>').toEqual(names[index]);
|
||||||
|
});
|
||||||
|
expect(notFound.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail to load unknown stats', () => {
|
||||||
|
const names = ['not a stat', StatID.max, StatID.min, 'also not a stat'];
|
||||||
|
const notFound: string[] = [];
|
||||||
|
const stats = getStatsCalculators(names, notFound);
|
||||||
|
expect(stats.length).toBe(2);
|
||||||
|
expect(notFound.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate stats', () => {
|
||||||
|
const stats = calculateStats({
|
||||||
|
data: basicTable,
|
||||||
|
columnIndex: 0,
|
||||||
|
stats: ['first', 'last', 'mean'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// First
|
||||||
|
expect(stats.first).toEqual(10);
|
||||||
|
|
||||||
|
// Last
|
||||||
|
expect(stats.last).toEqual(20);
|
||||||
|
|
||||||
|
// Mean
|
||||||
|
expect(stats.mean).toEqual(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support a single stat also', () => {
|
||||||
|
const stats = calculateStats({
|
||||||
|
data: basicTable,
|
||||||
|
columnIndex: 0,
|
||||||
|
stats: ['first', 'last', 'mean'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// First
|
||||||
|
expect(stats.first).toEqual(10);
|
||||||
|
|
||||||
|
// Last
|
||||||
|
expect(stats.last).toEqual(20);
|
||||||
|
|
||||||
|
// Mean
|
||||||
|
expect(stats.mean).toEqual(15);
|
||||||
|
});
|
||||||
|
});
|
326
packages/grafana-ui/src/utils/statsCalculator.ts
Normal file
326
packages/grafana-ui/src/utils/statsCalculator.ts
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
// Libraries
|
||||||
|
import isNumber from 'lodash/isNumber';
|
||||||
|
|
||||||
|
import { TableData, NullValueMode } from '../types/index';
|
||||||
|
|
||||||
|
export enum StatID {
|
||||||
|
sum = 'sum',
|
||||||
|
max = 'max',
|
||||||
|
min = 'min',
|
||||||
|
logmin = 'logmin',
|
||||||
|
mean = 'mean',
|
||||||
|
last = 'last',
|
||||||
|
first = 'first',
|
||||||
|
count = 'count',
|
||||||
|
range = 'range',
|
||||||
|
diff = 'diff',
|
||||||
|
delta = 'delta',
|
||||||
|
step = 'step',
|
||||||
|
|
||||||
|
allIsZero = 'allIsZero',
|
||||||
|
allIsNull = 'allIsNull',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColumnStats {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal function
|
||||||
|
type StatCalculator = (data: TableData, columnIndex: number, ignoreNulls: boolean, nullAsZero: boolean) => ColumnStats;
|
||||||
|
|
||||||
|
export interface StatCalculatorInfo {
|
||||||
|
value: string; // The ID - value maps directly to select component
|
||||||
|
label: string; // The name - label for Select component
|
||||||
|
description: string;
|
||||||
|
alias?: string; // optional secondary key. 'avg' vs 'mean', 'total' vs 'sum'
|
||||||
|
|
||||||
|
// Internal details
|
||||||
|
emptyInputResult?: any; // typically null, but some things like 'count' & 'sum' should be zero
|
||||||
|
standard: boolean; // The most common stats can all be calculated in a single pass
|
||||||
|
calculator?: StatCalculator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param ids list of stat names or null to get all of them
|
||||||
|
* @param notFound optional error object that will be filled with the names on unknown stats
|
||||||
|
*/
|
||||||
|
export function getStatsCalculators(ids?: string[], notFound?: string[]): StatCalculatorInfo[] {
|
||||||
|
if (ids === null || ids === undefined) {
|
||||||
|
return listOfStats;
|
||||||
|
}
|
||||||
|
return ids.reduce((list, id) => {
|
||||||
|
const stat = getById(id);
|
||||||
|
if (stat) {
|
||||||
|
list.push(stat);
|
||||||
|
} else if (notFound && id) {
|
||||||
|
notFound.push(id);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, new Array<StatCalculatorInfo>());
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalculateStatsOptions {
|
||||||
|
data: TableData;
|
||||||
|
columnIndex: number;
|
||||||
|
stats: string[]; // The stats to calculate
|
||||||
|
nullValueMode?: NullValueMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns an object with a key for each selected stat
|
||||||
|
*/
|
||||||
|
export function calculateStats(options: CalculateStatsOptions): ColumnStats {
|
||||||
|
const { data, columnIndex, stats, nullValueMode } = options;
|
||||||
|
|
||||||
|
if (!stats || stats.length < 1) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = getStatsCalculators(stats);
|
||||||
|
|
||||||
|
// Return early for empty tables
|
||||||
|
// This lets the concrete implementations assume at least one row
|
||||||
|
if (!data.rows || data.rows.length < 1) {
|
||||||
|
const stats = {} as ColumnStats;
|
||||||
|
queue.forEach(stat => {
|
||||||
|
stats[stat.value] = stat.emptyInputResult !== null ? stat.emptyInputResult : null;
|
||||||
|
});
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ignoreNulls = nullValueMode === NullValueMode.Ignore;
|
||||||
|
const nullAsZero = nullValueMode === NullValueMode.AsZero;
|
||||||
|
|
||||||
|
// Avoid calculating all the standard stats if possible
|
||||||
|
if (queue.length === 1 && queue[0].calculator) {
|
||||||
|
return [queue[0].calculator(data, columnIndex, ignoreNulls, nullAsZero)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now everything can use the standard stats
|
||||||
|
let values = standardStatsStat(data, columnIndex, ignoreNulls, nullAsZero);
|
||||||
|
queue.forEach(calc => {
|
||||||
|
if (!values.hasOwnProperty(calc.value) && calc.calculator) {
|
||||||
|
values = {
|
||||||
|
...values,
|
||||||
|
...calc.calculator(data, columnIndex, ignoreNulls, nullAsZero),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// No Exported symbols below here.
|
||||||
|
//
|
||||||
|
// ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// private registry of all stats
|
||||||
|
interface TableStatIndex {
|
||||||
|
[id: string]: StatCalculatorInfo;
|
||||||
|
}
|
||||||
|
const listOfStats: StatCalculatorInfo[] = [];
|
||||||
|
const index: TableStatIndex = {};
|
||||||
|
let hasBuiltIndex = false;
|
||||||
|
|
||||||
|
function getById(id: string): StatCalculatorInfo | undefined {
|
||||||
|
if (!hasBuiltIndex) {
|
||||||
|
[
|
||||||
|
{
|
||||||
|
value: StatID.last,
|
||||||
|
label: 'Last',
|
||||||
|
description: 'Last Value (current)',
|
||||||
|
standard: true,
|
||||||
|
alias: 'current',
|
||||||
|
stat: calculateLast,
|
||||||
|
},
|
||||||
|
{ value: StatID.first, label: 'First', description: 'First Value', standard: true, stat: calculateFirst },
|
||||||
|
{ value: StatID.min, label: 'Min', description: 'Minimum Value', standard: true },
|
||||||
|
{ value: StatID.max, label: 'Max', description: 'Maximum Value', standard: true },
|
||||||
|
{ value: StatID.mean, label: 'Mean', description: 'Average Value', standard: true, alias: 'avg' },
|
||||||
|
{
|
||||||
|
value: StatID.sum,
|
||||||
|
label: 'Total',
|
||||||
|
description: 'The sum of all values',
|
||||||
|
emptyInputResult: 0,
|
||||||
|
standard: true,
|
||||||
|
alias: 'total',
|
||||||
|
},
|
||||||
|
{ value: StatID.count, label: 'Count', description: 'Value Count', emptyInputResult: 0, standard: true },
|
||||||
|
{
|
||||||
|
value: StatID.range,
|
||||||
|
label: 'Range',
|
||||||
|
description: 'Difference between minimum and maximum values',
|
||||||
|
standard: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: StatID.delta,
|
||||||
|
label: 'Delta',
|
||||||
|
description: 'Cumulative change in value', // HELP! not totally sure what this does
|
||||||
|
standard: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: StatID.step,
|
||||||
|
label: 'Step',
|
||||||
|
description: 'Minimum interval between values',
|
||||||
|
standard: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: StatID.diff,
|
||||||
|
label: 'Difference',
|
||||||
|
description: 'Difference between first and last values',
|
||||||
|
standard: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: StatID.logmin,
|
||||||
|
label: 'Min (above zero)',
|
||||||
|
description: 'Used for log min scale',
|
||||||
|
standard: true,
|
||||||
|
},
|
||||||
|
].forEach(calc => {
|
||||||
|
const { value, alias } = calc;
|
||||||
|
if (index.hasOwnProperty(value)) {
|
||||||
|
console.warn('Duplicate Stat', value, calc, index);
|
||||||
|
}
|
||||||
|
index[value] = calc;
|
||||||
|
if (alias) {
|
||||||
|
if (index.hasOwnProperty(alias)) {
|
||||||
|
console.warn('Duplicate Stat (alias)', alias, calc, index);
|
||||||
|
}
|
||||||
|
index[alias] = calc;
|
||||||
|
}
|
||||||
|
listOfStats.push(calc);
|
||||||
|
});
|
||||||
|
hasBuiltIndex = true;
|
||||||
|
}
|
||||||
|
return index[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
function standardStatsStat(
|
||||||
|
data: TableData,
|
||||||
|
columnIndex: number,
|
||||||
|
ignoreNulls: boolean,
|
||||||
|
nullAsZero: boolean
|
||||||
|
): ColumnStats {
|
||||||
|
const stats = {
|
||||||
|
sum: 0,
|
||||||
|
max: -Number.MAX_VALUE,
|
||||||
|
min: Number.MAX_VALUE,
|
||||||
|
logmin: Number.MAX_VALUE,
|
||||||
|
mean: null,
|
||||||
|
last: null,
|
||||||
|
first: null,
|
||||||
|
count: 0,
|
||||||
|
nonNullCount: 0,
|
||||||
|
allIsNull: true,
|
||||||
|
allIsZero: false,
|
||||||
|
range: null,
|
||||||
|
diff: null,
|
||||||
|
delta: 0,
|
||||||
|
step: 0,
|
||||||
|
|
||||||
|
// Just used for calcutations -- not exposed as a stat
|
||||||
|
previousDeltaUp: true,
|
||||||
|
} as ColumnStats;
|
||||||
|
|
||||||
|
for (let i = 0; i < data.rows.length; i++) {
|
||||||
|
let currentValue = data.rows[i][columnIndex];
|
||||||
|
|
||||||
|
if (currentValue === null) {
|
||||||
|
if (ignoreNulls) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (nullAsZero) {
|
||||||
|
currentValue = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentValue !== null) {
|
||||||
|
stats.last = currentValue;
|
||||||
|
|
||||||
|
const isFirst = stats.first === null;
|
||||||
|
if (isFirst) {
|
||||||
|
stats.first = currentValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNumber(currentValue)) {
|
||||||
|
stats.sum += currentValue;
|
||||||
|
stats.allIsNull = false;
|
||||||
|
stats.nonNullCount++;
|
||||||
|
|
||||||
|
if (!isFirst) {
|
||||||
|
const step = currentValue - stats.last!;
|
||||||
|
if (stats.step > step) {
|
||||||
|
stats.step = step; // the minimum interval
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.last! > currentValue) {
|
||||||
|
// counter reset
|
||||||
|
stats.previousDeltaUp = false;
|
||||||
|
if (i === data.rows.length - 1) {
|
||||||
|
// reset on last
|
||||||
|
stats.delta += currentValue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (stats.previousDeltaUp) {
|
||||||
|
stats.delta += step; // normal increment
|
||||||
|
} else {
|
||||||
|
stats.delta += currentValue; // account for counter reset
|
||||||
|
}
|
||||||
|
stats.previousDeltaUp = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentValue > stats.max) {
|
||||||
|
stats.max = currentValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentValue < stats.min) {
|
||||||
|
stats.min = currentValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentValue < stats.logmin && currentValue > 0) {
|
||||||
|
stats.logmin = currentValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentValue !== 0) {
|
||||||
|
stats.allIsZero = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.last = currentValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.max === -Number.MAX_VALUE) {
|
||||||
|
stats.max = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.min === Number.MAX_VALUE) {
|
||||||
|
stats.min = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.nonNullCount > 0) {
|
||||||
|
stats.mean = stats.sum! / stats.nonNullCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.max !== null && stats.min !== null) {
|
||||||
|
stats.range = stats.max - stats.min;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.first !== null && stats.last !== null) {
|
||||||
|
if (isNumber(stats.first) && isNumber(stats.last)) {
|
||||||
|
stats.diff = stats.last - stats.first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateFirst(data: TableData, columnIndex: number, ignoreNulls: boolean, nullAsZero: boolean): ColumnStats {
|
||||||
|
return { first: data.rows[0][columnIndex] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateLast(data: TableData, columnIndex: number, ignoreNulls: boolean, nullAsZero: boolean): ColumnStats {
|
||||||
|
return { last: data.rows[data.rows.length - 1][columnIndex] };
|
||||||
|
}
|
@ -1,84 +0,0 @@
|
|||||||
import { parseCSV } from './processTableData';
|
|
||||||
import { reduceTableData, getTableReducers, TableReducerID } from './tableReducer';
|
|
||||||
|
|
||||||
describe('Table Reducer', () => {
|
|
||||||
const basicTable = parseCSV('a,b,c\n10,20,30\n20,30,40');
|
|
||||||
|
|
||||||
it('should load all standard stats', () => {
|
|
||||||
const names = [
|
|
||||||
TableReducerID.sum,
|
|
||||||
TableReducerID.max,
|
|
||||||
TableReducerID.min,
|
|
||||||
TableReducerID.logmin,
|
|
||||||
TableReducerID.mean,
|
|
||||||
TableReducerID.last,
|
|
||||||
TableReducerID.first,
|
|
||||||
TableReducerID.count,
|
|
||||||
TableReducerID.range,
|
|
||||||
TableReducerID.diff,
|
|
||||||
TableReducerID.step,
|
|
||||||
TableReducerID.delta,
|
|
||||||
// TableReducerID.allIsZero,
|
|
||||||
// TableReducerID.allIsNull,
|
|
||||||
];
|
|
||||||
const notFound: string[] = [];
|
|
||||||
const reducers = getTableReducers(names, notFound);
|
|
||||||
reducers.forEach((reducer, index) => {
|
|
||||||
expect(reducer ? reducer.value : '<missing>').toEqual(names[index]);
|
|
||||||
});
|
|
||||||
expect(notFound.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fail to load unknown reducers', () => {
|
|
||||||
const names = ['not a reducer', TableReducerID.max, TableReducerID.min, 'also not a reducer'];
|
|
||||||
const notFound: string[] = [];
|
|
||||||
const reducers = getTableReducers(names, notFound);
|
|
||||||
expect(reducers.length).toBe(2);
|
|
||||||
expect(notFound.length).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should calculate stats', () => {
|
|
||||||
const reduced = reduceTableData(basicTable, {
|
|
||||||
columnIndexes: [0, 1],
|
|
||||||
stats: ['first', 'last', 'mean'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(reduced.length).toBe(3);
|
|
||||||
|
|
||||||
// First
|
|
||||||
expect(reduced[0].rows[0]).toEqual([10, 20]);
|
|
||||||
|
|
||||||
// Last
|
|
||||||
expect(reduced[1].rows[0]).toEqual([20, 30]);
|
|
||||||
|
|
||||||
// Mean
|
|
||||||
expect(reduced[2].rows[0]).toEqual([15, 25]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support a single stat also', () => {
|
|
||||||
// First
|
|
||||||
let reduced = reduceTableData(basicTable, {
|
|
||||||
columnIndexes: [0, 1],
|
|
||||||
stats: ['first'],
|
|
||||||
});
|
|
||||||
expect(reduced.length).toBe(1);
|
|
||||||
expect(reduced[0].rows[0]).toEqual([10, 20]);
|
|
||||||
|
|
||||||
// Last
|
|
||||||
reduced = reduceTableData(basicTable, {
|
|
||||||
columnIndexes: [0, 1],
|
|
||||||
stats: ['last'],
|
|
||||||
});
|
|
||||||
expect(reduced.length).toBe(1);
|
|
||||||
expect(reduced[0].rows[0]).toEqual([20, 30]);
|
|
||||||
|
|
||||||
// Mean
|
|
||||||
reduced = reduceTableData(basicTable, {
|
|
||||||
columnIndexes: [0, 1],
|
|
||||||
stats: ['mean'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(reduced.length).toBe(1);
|
|
||||||
expect(reduced[0].rows[0]).toEqual([15, 25]);
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,373 +0,0 @@
|
|||||||
// Libraries
|
|
||||||
import isNumber from 'lodash/isNumber';
|
|
||||||
|
|
||||||
import { TableData, NullValueMode } from '../types/index';
|
|
||||||
|
|
||||||
export enum TableReducerID {
|
|
||||||
sum = 'sum',
|
|
||||||
max = 'max',
|
|
||||||
min = 'min',
|
|
||||||
logmin = 'logmin',
|
|
||||||
mean = 'mean',
|
|
||||||
last = 'last',
|
|
||||||
first = 'first',
|
|
||||||
count = 'count',
|
|
||||||
range = 'range',
|
|
||||||
diff = 'diff',
|
|
||||||
delta = 'delta',
|
|
||||||
step = 'step',
|
|
||||||
|
|
||||||
allIsZero = 'allIsZero',
|
|
||||||
allIsNull = 'allIsNull',
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Information about the reducing(stats) functions */
|
|
||||||
export interface TableReducerInfo {
|
|
||||||
value: string; // The ID - value maps directly to select component
|
|
||||||
label: string; // The name - label
|
|
||||||
description: string;
|
|
||||||
alias?: string; // optional secondary key. 'avg' vs 'mean', 'total' vs 'sum'
|
|
||||||
|
|
||||||
// Internal details
|
|
||||||
emptyInputResult?: any; // typically null, but some things like 'count' & 'sum' should be zero
|
|
||||||
standard: boolean; // The most common stats can all be calculated in a single pass
|
|
||||||
reducer?: TableReducer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a list of the known reducing functions
|
|
||||||
* @param ids list of reducer names or null to get all of them
|
|
||||||
* @param notFound optional error object that will be filled with the names on unknown reducers
|
|
||||||
*/
|
|
||||||
export function getTableReducers(ids?: string[], notFound?: string[]): TableReducerInfo[] {
|
|
||||||
if (ids === null || ids === undefined) {
|
|
||||||
return listOfReducers;
|
|
||||||
}
|
|
||||||
return ids.reduce((list, id) => {
|
|
||||||
const reducer = getById(id);
|
|
||||||
if (reducer) {
|
|
||||||
list.push(reducer);
|
|
||||||
} else if (notFound && id) {
|
|
||||||
notFound.push(id);
|
|
||||||
}
|
|
||||||
return list;
|
|
||||||
}, new Array<TableReducerInfo>());
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TableReducerOptions {
|
|
||||||
columnIndexes?: number[];
|
|
||||||
nullValueMode?: NullValueMode;
|
|
||||||
stats: string[]; // The stats to calculate
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reduceTableData(data: TableData, options: TableReducerOptions): TableData[] {
|
|
||||||
const indexes = verifyColumns(data, options);
|
|
||||||
const columns = indexes.map(v => data.columns[v]);
|
|
||||||
|
|
||||||
const ignoreNulls = options.nullValueMode === NullValueMode.Ignore;
|
|
||||||
const nullAsZero = options.nullValueMode === NullValueMode.AsZero;
|
|
||||||
|
|
||||||
const queue = getTableReducers(options.stats);
|
|
||||||
|
|
||||||
// Return early for empty tables
|
|
||||||
// This lets the concrete implementations assume at least one row
|
|
||||||
if (!data.rows || data.rows.length < 1) {
|
|
||||||
return queue.map(stat => {
|
|
||||||
return {
|
|
||||||
columns,
|
|
||||||
rows: [indexes.map(v => stat.emptyInputResult)],
|
|
||||||
type: 'table',
|
|
||||||
columnMap: {},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Avoid calculating all the standard stats if possible
|
|
||||||
if (queue.length === 1 && queue[0].reducer) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
columns,
|
|
||||||
rows: [queue[0].reducer(data, indexes, ignoreNulls, nullAsZero)],
|
|
||||||
type: 'table',
|
|
||||||
columnMap: {},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// For now everything can use the standard stats
|
|
||||||
const standard = standardStatsReducer(data, indexes, ignoreNulls, nullAsZero);
|
|
||||||
return queue.map(calc => {
|
|
||||||
const values = calc.standard
|
|
||||||
? standard.map((s: any) => s[calc.value])
|
|
||||||
: calc.reducer!(data, indexes, ignoreNulls, nullAsZero);
|
|
||||||
return {
|
|
||||||
columns,
|
|
||||||
rows: [values],
|
|
||||||
type: 'table',
|
|
||||||
columnMap: {},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// No Exported symbols below here.
|
|
||||||
//
|
|
||||||
// ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type TableReducer = (data: TableData, columnIndexes: number[], ignoreNulls: boolean, nullAsZero: boolean) => any[];
|
|
||||||
|
|
||||||
// private registry of all reducers
|
|
||||||
interface TableReducerIndex {
|
|
||||||
[id: string]: TableReducerInfo;
|
|
||||||
}
|
|
||||||
const listOfReducers: TableReducerInfo[] = [];
|
|
||||||
const index: TableReducerIndex = {};
|
|
||||||
let hasBuiltIndex = false;
|
|
||||||
|
|
||||||
function getById(id: string): TableReducerInfo | undefined {
|
|
||||||
if (!hasBuiltIndex) {
|
|
||||||
[
|
|
||||||
{
|
|
||||||
value: TableReducerID.last,
|
|
||||||
label: 'Last',
|
|
||||||
description: 'Last Value (current)',
|
|
||||||
standard: true,
|
|
||||||
alias: 'current',
|
|
||||||
reducer: getLastRow,
|
|
||||||
},
|
|
||||||
{ value: TableReducerID.first, label: 'First', description: 'First Value', standard: true, reducer: getFirstRow },
|
|
||||||
{ value: TableReducerID.min, label: 'Min', description: 'Minimum Value', standard: true },
|
|
||||||
{ value: TableReducerID.max, label: 'Max', description: 'Maximum Value', standard: true },
|
|
||||||
{ value: TableReducerID.mean, label: 'Mean', description: 'Average Value', standard: true, alias: 'avg' },
|
|
||||||
{
|
|
||||||
value: TableReducerID.sum,
|
|
||||||
label: 'Total',
|
|
||||||
description: 'The sum of all values',
|
|
||||||
emptyInputResult: 0,
|
|
||||||
standard: true,
|
|
||||||
alias: 'total',
|
|
||||||
},
|
|
||||||
{ value: TableReducerID.count, label: 'Count', description: 'Value Count', emptyInputResult: 0, standard: true },
|
|
||||||
{
|
|
||||||
value: TableReducerID.range,
|
|
||||||
label: 'Range',
|
|
||||||
description: 'Difference between minimum and maximum values',
|
|
||||||
standard: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: TableReducerID.delta,
|
|
||||||
label: 'Delta',
|
|
||||||
description: 'Cumulative change in value', // HELP! not totally sure what this does
|
|
||||||
standard: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: TableReducerID.step,
|
|
||||||
label: 'Step',
|
|
||||||
description: 'Minimum interval between values',
|
|
||||||
standard: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: TableReducerID.diff,
|
|
||||||
label: 'Difference',
|
|
||||||
description: 'Difference between first and last values',
|
|
||||||
standard: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: TableReducerID.logmin,
|
|
||||||
label: 'Min (above zero)',
|
|
||||||
description: 'Used for log min scale',
|
|
||||||
standard: true,
|
|
||||||
},
|
|
||||||
].forEach(calc => {
|
|
||||||
const { value, alias } = calc;
|
|
||||||
if (index.hasOwnProperty(value)) {
|
|
||||||
console.warn('Duplicate Reducer', value, calc, index);
|
|
||||||
}
|
|
||||||
index[value] = calc;
|
|
||||||
if (alias) {
|
|
||||||
if (index.hasOwnProperty(alias)) {
|
|
||||||
console.warn('Duplicate Reducer (alias)', alias, calc, index);
|
|
||||||
}
|
|
||||||
index[alias] = calc;
|
|
||||||
}
|
|
||||||
listOfReducers.push(calc);
|
|
||||||
});
|
|
||||||
hasBuiltIndex = true;
|
|
||||||
}
|
|
||||||
return index[id];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This will return an array of valid indexes and throw an error if invalid request
|
|
||||||
*/
|
|
||||||
function verifyColumns(data: TableData, options: TableReducerOptions): number[] {
|
|
||||||
const { columnIndexes } = options;
|
|
||||||
if (!columnIndexes) {
|
|
||||||
return data.columns.map((v, idx) => idx);
|
|
||||||
}
|
|
||||||
columnIndexes.forEach(v => {
|
|
||||||
if (v < 0 || v >= data.columns.length) {
|
|
||||||
throw new Error('Invalid column selection: ' + v);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return columnIndexes;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StandardStats {
|
|
||||||
sum: number | null; // total
|
|
||||||
max: number | null;
|
|
||||||
min: number | null;
|
|
||||||
logmin: number;
|
|
||||||
mean: number | null; // avg
|
|
||||||
last: any; // current
|
|
||||||
first: any;
|
|
||||||
count: number;
|
|
||||||
nonNullCount: number;
|
|
||||||
range: number | null;
|
|
||||||
diff: number | null;
|
|
||||||
delta: number | null;
|
|
||||||
step: number | null;
|
|
||||||
allIsZero: boolean;
|
|
||||||
allIsNull: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function standardStatsReducer(
|
|
||||||
data: TableData,
|
|
||||||
columnIndexes: number[],
|
|
||||||
ignoreNulls: boolean,
|
|
||||||
nullAsZero: boolean
|
|
||||||
): StandardStats[] {
|
|
||||||
const column = columnIndexes.map(idx => {
|
|
||||||
return {
|
|
||||||
sum: 0,
|
|
||||||
max: -Number.MAX_VALUE,
|
|
||||||
min: Number.MAX_VALUE,
|
|
||||||
logmin: Number.MAX_VALUE,
|
|
||||||
mean: null,
|
|
||||||
last: null,
|
|
||||||
first: null,
|
|
||||||
count: 0,
|
|
||||||
nonNullCount: 0,
|
|
||||||
allIsNull: true,
|
|
||||||
allIsZero: false,
|
|
||||||
range: null,
|
|
||||||
diff: null,
|
|
||||||
delta: 0,
|
|
||||||
step: 0,
|
|
||||||
|
|
||||||
// Just used for calcutations -- not exposed as a reducer
|
|
||||||
previousDeltaUp: true,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let i = 0; i < data.rows.length; i++) {
|
|
||||||
for (let x = 0; x < column.length; x++) {
|
|
||||||
const stats = column[x];
|
|
||||||
let currentValue = data.rows[i][x];
|
|
||||||
|
|
||||||
if (currentValue === null) {
|
|
||||||
if (ignoreNulls) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (nullAsZero) {
|
|
||||||
currentValue = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentValue !== null) {
|
|
||||||
stats.last = currentValue;
|
|
||||||
|
|
||||||
const isFirst = stats.first === null;
|
|
||||||
if (isFirst) {
|
|
||||||
stats.first = currentValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNumber(currentValue)) {
|
|
||||||
stats.sum += currentValue;
|
|
||||||
stats.allIsNull = false;
|
|
||||||
stats.nonNullCount++;
|
|
||||||
|
|
||||||
if (!isFirst) {
|
|
||||||
const step = currentValue - stats.last!;
|
|
||||||
if (stats.step > step) {
|
|
||||||
stats.step = step; // the minimum interval
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stats.last! > currentValue) {
|
|
||||||
// counter reset
|
|
||||||
stats.previousDeltaUp = false;
|
|
||||||
if (i === data.rows.length - 1) {
|
|
||||||
// reset on last
|
|
||||||
stats.delta += currentValue;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (stats.previousDeltaUp) {
|
|
||||||
stats.delta += step; // normal increment
|
|
||||||
} else {
|
|
||||||
stats.delta += currentValue; // account for counter reset
|
|
||||||
}
|
|
||||||
stats.previousDeltaUp = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentValue > stats.max) {
|
|
||||||
stats.max = currentValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentValue < stats.min) {
|
|
||||||
stats.min = currentValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentValue < stats.logmin && currentValue > 0) {
|
|
||||||
stats.logmin = currentValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentValue !== 0) {
|
|
||||||
stats.allIsZero = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.last = currentValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let x = 0; x < column.length; x++) {
|
|
||||||
const stats = column[x] as StandardStats;
|
|
||||||
|
|
||||||
if (stats.max === -Number.MAX_VALUE) {
|
|
||||||
stats.max = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stats.min === Number.MAX_VALUE) {
|
|
||||||
stats.min = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stats.nonNullCount > 0) {
|
|
||||||
stats.mean = stats.sum! / stats.nonNullCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stats.max !== null && stats.min !== null) {
|
|
||||||
stats.range = stats.max - stats.min;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stats.first !== null && stats.last !== null) {
|
|
||||||
if (isNumber(stats.first) && isNumber(stats.last)) {
|
|
||||||
stats.diff = stats.last - stats.first;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return column;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFirstRow(data: TableData, columnIndexes: number[], ignoreNulls: boolean, nullAsZero: boolean): any[] {
|
|
||||||
const row = data.rows[0];
|
|
||||||
return columnIndexes.map(idx => row[idx]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLastRow(data: TableData, columnIndexes: number[], ignoreNulls: boolean, nullAsZero: boolean): any[] {
|
|
||||||
const row = data.rows[data.rows.length - 1];
|
|
||||||
return columnIndexes.map(idx => row[idx]);
|
|
||||||
}
|
|
Loading…
Reference in New Issue
Block a user