Skip to content

Commit

Permalink
feat: expose computeRatioByGroups fn (#1495)
Browse files Browse the repository at this point in the history
This commit exposes the function to compute the participation ratio of a value in the total sum of its membership group. It can be used to compute non-stacked percentage bar charts.
  • Loading branch information
markov00 authored Nov 23, 2021
1 parent 1c32f82 commit 65f4886
Show file tree
Hide file tree
Showing 13 changed files with 162 additions and 30 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions integration/tests/bar_stories.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,4 +223,21 @@ describe('Bar series stories', () => {
);
});
});
describe('Stacked bars configs', () => {
it('percentage stacked with internal fn', async () => {
await common.expectChartAtUrlToMatchScreenshot(
'http://localhost:9001/?path=/story/bar-chart--stacked-as-percentage&globals=theme:light&knob-mode=stackAsPercentage&knob-use computeRatioByGroups fn=',
);
});
it('percentage stacked with external fn', async () => {
await common.expectChartAtUrlToMatchScreenshot(
'http://localhost:9001/?path=/story/bar-chart--stacked-as-percentage&globals=theme:light&knob-mode=stackAsPercentage&knob-use computeRatioByGroups fn=true',
);
});
it('non stacked with external fn', async () => {
await common.expectChartAtUrlToMatchScreenshot(
'http://localhost:9001/?path=/story/bar-chart--stacked-as-percentage&globals=theme:light&knob-mode=unstacked&knob-use computeRatioByGroups fn=true',
);
});
});
});
11 changes: 11 additions & 0 deletions packages/charts/api/charts.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,11 @@ export type ColorVariant = $Values<typeof ColorVariant>;
// @public
export type ComponentWithAnnotationDatum = ComponentType<LineAnnotationDatum>;

// @public
export function computeRatioByGroups<T extends Record<string, unknown>>(data: T[], groupAccessors: GroupKeysOrKeyFn<T>, valueAccessor: (k: T) => number | null | undefined, ratioKeyName: string): (T & {
[x: string]: number | null | undefined;
})[];

// @public (undocumented)
export type ContinuousDomain = [min: number, max: number];

Expand Down Expand Up @@ -1021,6 +1026,9 @@ export type GroupByAccessor = (spec: Spec, datum: any) => string | number;
// @public
export type GroupByFormatter = (value: ReturnType<GroupByAccessor>) => string;

// @public (undocumented)
export type GroupByKeyFn<T> = (data: T) => string;

// @alpha (undocumented)
export type GroupByProps = Pick<GroupBySpec, 'id' | 'by' | 'sort' | 'format'>;

Expand All @@ -1039,6 +1047,9 @@ export interface GroupBySpec extends Spec {
// @public (undocumented)
export type GroupId = string;

// @public (undocumented)
export type GroupKeysOrKeyFn<T> = Array<keyof T> | GroupByKeyFn<T>;

// @alpha (undocumented)
export const Heatmap: React_2.FunctionComponent<Pick<HeatmapSpec, 'id' | 'data' | 'colorScale'> & Partial<Omit<HeatmapSpec, 'chartType' | 'specType' | 'id' | 'data'>>>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { createCustomCachedSelector } from '../../../../state/create_selector';
import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs';
import { getAccessorValue } from '../../../../utils/accessor';
import { addIntervalToTime, timeRange } from '../../../../utils/chrono/elasticsearch';
import { isFiniteNumber } from '../../../../utils/common';
import { HeatmapTable } from './compute_chart_dimensions';
import { getHeatmapConfigSelector } from './get_heatmap_config';
import { getHeatmapSpecSelector } from './get_heatmap_spec';
Expand Down Expand Up @@ -84,7 +85,3 @@ export const getHeatmapTableSelector = createCustomCachedSelector(
return resultData;
},
);

function isFiniteNumber(value: number | undefined): value is number {
return Number.isFinite(value);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@
*/

type Group<T> = Record<string, T[]>;
type GroupByKeyFn<T> = (data: T) => string;
type GroupKeysOrKeyFn<T> = Array<keyof T> | GroupByKeyFn<T>;

/** @public */
export type GroupByKeyFn<T> = (data: T) => string;

/** @public */
export type GroupKeysOrKeyFn<T> = Array<keyof T> | GroupByKeyFn<T>;

/** @internal */
export function groupBy<T>(data: T[], keysOrKeyFn: GroupKeysOrKeyFn<T>, asArray: false): Group<T>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {

import { SeriesKey } from '../../../common/series_id';
import { ScaleType } from '../../../scales/constants';
import { clamp } from '../../../utils/common';
import { clamp, isFiniteNumber } from '../../../utils/common';
import { DataSeries, DataSeriesDatum } from './series';
import { StackMode } from './specs';

Expand Down Expand Up @@ -59,6 +59,7 @@ export function formatStackedDataSeriesValues(
acc[curr.key] = curr;
return acc;
}, {});
const stackAsPercentage = stackMode === StackMode.Percentage;

const xValuesArray = [...xValues];
const reorderedArray: Array<D3StackArrayElement> = [];
Expand All @@ -77,10 +78,13 @@ export function formatStackedDataSeriesValues(
reorderedArray[xIndex] = { x };
}
// y0 can be considered as always present
reorderedArray[xIndex][`${key}-y0`] = y0;
reorderedArray[xIndex][`${key}-y0`] = isFiniteNumber(y0) ? (stackAsPercentage ? Math.abs(y0) : y0) : y0;
// if y0 is available, we have to count y1 as the different of y1 and y0
// to correctly stack them when stacking banded charts
reorderedArray[xIndex][`${key}-y1`] = (y1 ?? 0) - (y0 ?? 0);
const nonNullY1 = y1 ?? 0;
const nonNullY0 = y0 ?? 0;
reorderedArray[xIndex][`${key}-y1`] =
(stackAsPercentage ? Math.abs(nonNullY1) : nonNullY1) - (stackAsPercentage ? Math.abs(nonNullY0) : nonNullY0);
dsMap.set(x, d);
});
xValueMap.set(key, dsMap);
Expand Down
4 changes: 4 additions & 0 deletions packages/charts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,7 @@ export {
ESFixedInterval,
ESFixedIntervalUnit,
} from './utils/chrono/elasticsearch';

// data utils
export { GroupKeysOrKeyFn, GroupByKeyFn } from './chart_types/xy_chart/utils/group_data_series';
export { computeRatioByGroups } from './utils/data/data_processing';
5 changes: 5 additions & 0 deletions packages/charts/src/utils/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,3 +617,8 @@ const oppositeAlignmentMap: Record<string, HorizontalAlignment | VerticalAlignme
export function getOppositeAlignment<A extends HorizontalAlignment | VerticalAlignment>(alignment: A): A {
return (oppositeAlignmentMap[alignment] as A) ?? alignment;
}

/** @internal */
export function isFiniteNumber(value: unknown): value is number {
return Number.isFinite(value);
}
55 changes: 55 additions & 0 deletions packages/charts/src/utils/data/data_processing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { groupBy, GroupKeysOrKeyFn } from '../../chart_types/xy_chart/utils/group_data_series';
import { isFiniteNumber } from '../common';

/**
* The function computes the participation ratio of a value in the total sum of its membership group.
* It returns a shallow copy of the input array where each object is augmented with the computed ratio.
*
* @remarks
* The ratio is computed using absolute values.
* Product A made a profit of $200, and product B has a loss of $300. In total, the company lost $100 ($200 – $300).
* Product A has a weight of: abs(200) / ( abs(200) + abs(-300) ) * 100% = 40%
* Product B has a weight of: abs(-300) / ( abs(200) + abs(-300) ) * 100% = 60%
* Product A and product B have respectively a weight of 40% and 60% in the formation of the overall total loss of $100.
*
* We don't compute the ratio for non-finite values. In this case, we return the original non-finite value.
*
* If the sum of the group values is 0, each ratio is considered 0.
*
* @public
* @param data - an array of objects
* @param groupAccessors - an array of accessor keys or a fn to describe an unique id for each group
* @param valueAccessor - a fn that returns the value to use
* @param ratioKeyName - the object key used to store the computed ratio
*/
export function computeRatioByGroups<T extends Record<string, unknown>>(
data: T[],
groupAccessors: GroupKeysOrKeyFn<T>,
valueAccessor: (k: T) => number | null | undefined,
ratioKeyName: string,
) {
return groupBy(data, groupAccessors, true)
.map((groupedData) => {
const groupSum = groupedData.reduce((sum, datum) => {
const value = valueAccessor(datum);
return sum + (isFiniteNumber(value) ? Math.abs(value) : 0);
}, 0);
return groupedData.map((datum) => {
const value = valueAccessor(datum);
return {
...datum,
// if the value is null/undefined we don't compute the ratio, we just return the original null/undefined value
[ratioKeyName]: isFiniteNumber(value) ? (groupSum === 0 ? 0 : Math.abs(value) / groupSum) : value,
};
});
})
.flat();
}
77 changes: 56 additions & 21 deletions storybook/stories/bar/12_stacked_as_percentage.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,47 +6,82 @@
* Side Public License, v 1.
*/

import { boolean } from '@storybook/addon-knobs';
import { boolean, select } from '@storybook/addon-knobs';
import React from 'react';

import { Axis, BarSeries, Chart, Position, ScaleType, Settings, StackMode } from '@elastic/charts';
import {
Axis,
BarSeries,
Chart,
Position,
ScaleType,
Settings,
StackMode,
computeRatioByGroups,
} from '@elastic/charts';

import { AnnotationDomainType, LineAnnotation } from '../../../packages/charts/src/chart_types/specs';
import { useBaseTheme } from '../../use_base_theme';

export const Example = () => {
const stackMode = boolean('stacked as percentage', true) ? StackMode.Percentage : undefined;
const modes = select(
'mode',
{ stack: 'stack', stackAsPercentage: 'stackAsPercentage', unstacked: 'unstacked' },
'stackAsPercentage',
);
const stack = modes !== 'unstacked' ? ['x'] : undefined;
const stackMode = modes === 'stackAsPercentage' ? StackMode.Percentage : undefined;

const originalData = [
{ x: 'pos/neg', y: -10, g: 'a' },
{ x: 'pos/neg', y: 10, g: 'b' },

{ x: 'zero/zero', y: 0, g: 'a' },
{ x: 'zero/zero', y: 0, g: 'b' },

{ x: 'zero/pos', y: 0, g: 'a' },
{ x: 'zero/pos', y: 10, g: 'b' },

{ x: 'null/pos', y: null, g: 'a' },
{ x: 'null/pos', y: 10, g: 'b' },

{ x: 'pos/pos', y: 2, g: 'a' },
{ x: 'pos/pos', y: 8, g: 'b' },

{ x: 'neg/neg', y: -2, g: 'a' },
{ x: 'neg/neg', y: -8, g: 'b' },
];

const data = boolean('use computeRatioByGroups fn', false)
? computeRatioByGroups(originalData, ['x'], (d) => d.y, 'y')
: originalData;

return (
<Chart>
<Settings showLegend showLegendExtra legendPosition={Position.Right} baseTheme={useBaseTheme()} />
<Axis id="bottom" position={Position.Bottom} title="Bottom axis" showOverlappingTicks />
<Settings baseTheme={useBaseTheme()} />
<Axis id="bottom" position={Position.Bottom} />

<Axis
id="left2"
title="Left axis"
position={Position.Left}
tickFormat={(d: any) => (stackMode === StackMode.Percentage ? `${Number(d * 100).toFixed(0)} %` : d)}
showGridLines
ticks={5}
style={{ axisLine: { visible: false }, tickLine: { visible: false }, tickLabel: { padding: 5 } }}
gridLine={{ stroke: 'rgba(128,128,128,0.5)', strokeWidth: 0.5 }}
tickFormat={(d: any) => (modes === 'stackAsPercentage' ? `${Number(d * 100).toFixed(0)} %` : `${d}`)}
/>

<BarSeries
id="bars"
xScaleType={ScaleType.Linear}
xScaleType={ScaleType.Ordinal}
yScaleType={ScaleType.Linear}
xAccessor="x"
yAccessors={['y']}
stackMode={stackMode}
stackAccessors={['x']}
stackAccessors={stack}
stackMode={stack && stackMode}
splitSeriesAccessors={['g']}
data={[
{ x: 0, y: 4, g: 'b' },
{ x: 1, y: 5, g: 'b' },
{ x: 2, y: 8, g: 'b' },
{ x: 3, y: 2, g: 'b' },
{ x: 0, y: 2, g: 'a' },
{ x: 1, y: 2, g: 'a' },
{ x: 2, y: 0, g: 'a' },
{ x: 3, y: null, g: 'a' },
]}
data={data}
/>
<LineAnnotation dataValues={[{ dataValue: 0 }]} id="baseline" domainType={AnnotationDomainType.YDomain} />
</Chart>
);
};

0 comments on commit 65f4886

Please sign in to comment.