From d7ddda312be3c1c820e7dd4a3d38aa6eabbddea6 Mon Sep 17 00:00:00 2001 From: Marco Vettorello Date: Wed, 26 Jun 2019 15:54:27 +0200 Subject: [PATCH 1/9] feat(stack): stack series in percentage mode This commit will add a new props that allows stacking bars, areas and lines using the percentage mode. For each stacked x value, we scale their value to the percentage on that bucket. fix #222 --- src/lib/series/domains/y_domain.ts | 55 +++++++---- src/lib/series/legend.ts | 2 +- src/lib/series/series.ts | 10 +- src/lib/series/specs.ts | 4 + src/lib/series/stacked_series_utils.test.ts | 9 +- src/lib/series/stacked_series_utils.ts | 101 +++++++++++++++----- src/specs/bar_series.tsx | 1 + src/state/utils.ts | 26 ++++- 8 files changed, 155 insertions(+), 53 deletions(-) diff --git a/src/lib/series/domains/y_domain.ts b/src/lib/series/domains/y_domain.ts index 0cf36d95cf..c63d9ea562 100644 --- a/src/lib/series/domains/y_domain.ts +++ b/src/lib/series/domains/y_domain.ts @@ -16,7 +16,14 @@ export type YDomain = BaseDomain & { }; export type YBasicSeriesSpec = Pick< BasicSeriesSpec, - 'id' | 'seriesType' | 'yScaleType' | 'groupId' | 'stackAccessors' | 'yScaleToDataExtent' | 'colorAccessors' + | 'id' + | 'seriesType' + | 'yScaleType' + | 'groupId' + | 'stackAccessors' + | 'yScaleToDataExtent' + | 'colorAccessors' + | 'stackAsPercentage' >; export function mergeYDomain( @@ -32,27 +39,35 @@ export function mergeYDomain( const yDomains = specsByGroupIdsEntries.map( ([groupId, groupSpecs]): YDomain => { const groupYScaleType = coerceYScaleTypes([...groupSpecs.stacked, ...groupSpecs.nonStacked]); - - // compute stacked domain - const isStackedScaleToExtent = groupSpecs.stacked.some((spec) => { - return spec.yScaleToDataExtent; + const isPercentageStack = groupSpecs.stacked.some((spec) => { + return Boolean(spec.stackAsPercentage); }); - const stackedDataSeries = getDataSeriesOnGroup(dataSeries, groupSpecs.stacked); - const stackedDomain = computeYStackedDomain(stackedDataSeries, isStackedScaleToExtent); - // compute non stacked domain - const isNonStackedScaleToExtent = groupSpecs.nonStacked.some((spec) => { - return spec.yScaleToDataExtent; - }); - const nonStackedDataSeries = getDataSeriesOnGroup(dataSeries, groupSpecs.nonStacked); - const nonStackedDomain = computeYNonStackedDomain(nonStackedDataSeries, isNonStackedScaleToExtent); - - // merge stacked and non stacked domain together - const groupDomain = computeContinuousDataDomain( - [...stackedDomain, ...nonStackedDomain], - identity, - isStackedScaleToExtent || isNonStackedScaleToExtent, - ); + let groupDomain: number[]; + if (isPercentageStack) { + groupDomain = computeContinuousDataDomain([0, 1], identity); + } else { + // compute stacked domain + const isStackedScaleToExtent = groupSpecs.stacked.some((spec) => { + return spec.yScaleToDataExtent; + }); + const stackedDataSeries = getDataSeriesOnGroup(dataSeries, groupSpecs.stacked); + const stackedDomain = computeYStackedDomain(stackedDataSeries, isStackedScaleToExtent); + + // compute non stacked domain + const isNonStackedScaleToExtent = groupSpecs.nonStacked.some((spec) => { + return spec.yScaleToDataExtent; + }); + const nonStackedDataSeries = getDataSeriesOnGroup(dataSeries, groupSpecs.nonStacked); + const nonStackedDomain = computeYNonStackedDomain(nonStackedDataSeries, isNonStackedScaleToExtent); + + // merge stacked and non stacked domain together + groupDomain = computeContinuousDataDomain( + [...stackedDomain, ...nonStackedDomain], + identity, + isStackedScaleToExtent || isNonStackedScaleToExtent, + ); + } const [computedDomainMin, computedDomainMax] = groupDomain; let domain = groupDomain; diff --git a/src/lib/series/legend.ts b/src/lib/series/legend.ts index dec81ecbd6..0c24de0cb5 100644 --- a/src/lib/series/legend.ts +++ b/src/lib/series/legend.ts @@ -56,7 +56,7 @@ export function computeLegend( isLegendItemVisible: !hideInLegend, displayValue: { raw: series.lastValue, - formatted: formatter(series.lastValue), + formatted: isSeriesVisible ? formatter(series.lastValue) : undefined, }, }); }); diff --git a/src/lib/series/series.ts b/src/lib/series/series.ts index 14e5558758..0c5d27c136 100644 --- a/src/lib/series/series.ts +++ b/src/lib/series/series.ts @@ -219,12 +219,20 @@ export function getFormattedDataseries( }[] = []; specsByGroupIdsEntries.forEach(([groupId, groupSpecs]) => { + const isPercentageStack = groupSpecs.stacked.some((spec) => { + return Boolean(spec.stackAsPercentage); + }); // format stacked data series const stackedDataSeries = getRawDataSeries(groupSpecs.stacked, dataSeries); + const stackedDataSeriesValues = formatStackedDataSeriesValues( + stackedDataSeries.rawDataSeries, + false, + isPercentageStack, + ); stackedFormattedDataSeries.push({ groupId, counts: stackedDataSeries.counts, - dataSeries: formatStackedDataSeriesValues(stackedDataSeries.rawDataSeries, false), + dataSeries: stackedDataSeriesValues, }); // format non stacked data series diff --git a/src/lib/series/specs.ts b/src/lib/series/specs.ts index 993ae85ae6..5d7cf5e688 100644 --- a/src/lib/series/specs.ts +++ b/src/lib/series/specs.ts @@ -96,6 +96,10 @@ export interface SeriesAccessors { splitSeriesAccessors?: Accessor[]; /** An array of fields thats indicates the stack membership */ stackAccessors?: Accessor[]; + /** + * Stack each series in percentage for each point. + */ + stackAsPercentage?: boolean; /** An optional array of field name thats indicates the stack membership */ colorAccessors?: Accessor[]; } diff --git a/src/lib/series/stacked_series_utils.test.ts b/src/lib/series/stacked_series_utils.test.ts index 3da6e6c22f..b8621c4b62 100644 --- a/src/lib/series/stacked_series_utils.test.ts +++ b/src/lib/series/stacked_series_utils.test.ts @@ -182,6 +182,7 @@ describe('Stacked Series Utils', () => { expect(x0StackArray).toBeDefined(); expect(x0StackArray.length).toBe(3); expect(x0StackArray).toEqual([10, 20, 30]); + // expect(x0StackArray).toEqual([10, 20, 30]); }); test('with values with nulls', () => { const stackedMap = getYValueStackMap(WITH_NULL_DATASET); @@ -206,7 +207,9 @@ describe('Stacked Series Utils', () => { expect(computedStackedMap.size).toBe(1); const x0Array = computedStackedMap.get(0)!; expect(x0Array).toBeDefined(); - expect(x0Array).toEqual([0, 10, 30, 60]); + expect(x0Array.values).toEqual([0, 10, 30, 60]); + expect(x0Array.percent).toEqual([0, 0.16666666666666666, 0.5, 1]); + expect(x0Array.total).toBe(60); }); test('with null values', () => { const stackedMap = getYValueStackMap(WITH_NULL_DATASET); @@ -214,7 +217,9 @@ describe('Stacked Series Utils', () => { expect(computedStackedMap.size).toBe(1); const x0Array = computedStackedMap.get(0)!; expect(x0Array).toBeDefined(); - expect(x0Array).toEqual([0, 10, 10, 40]); + expect(x0Array.values).toEqual([0, 10, 10, 40]); + expect(x0Array.percent).toEqual([0, 0.25, 0.25, 1]); + expect(x0Array.total).toBe(40); }); }); describe('Format stacked dataset', () => { diff --git a/src/lib/series/stacked_series_utils.ts b/src/lib/series/stacked_series_utils.ts index 17cb1a5fa4..86ae75c233 100644 --- a/src/lib/series/stacked_series_utils.ts +++ b/src/lib/series/stacked_series_utils.ts @@ -26,28 +26,65 @@ export function getYValueStackMap(dataseries: RawDataSeries[]): Map, scaleToExtent: boolean, -): Map { - const stackedValues = new Map(); +): Map< + any, + { + values: number[]; + percent: number[]; + total: number; + } +> { + const stackedValues = new Map< + any, + { + values: number[]; + percent: number[]; + total: number; + } + >(); yValueStackMap.forEach((yStackArray, xValue) => { const stackArray = yStackArray.reduce( - (stackedValue, currentValue, index) => { - if (stackedValue.length === 0) { + (acc, currentValue, index) => { + if (acc.values.length === 0) { if (scaleToExtent) { - return [currentValue, currentValue]; + return { + values: [currentValue, currentValue], + total: currentValue, + }; } - return [0, currentValue]; + return { + values: [0, currentValue], + total: currentValue, + }; } - return [...stackedValue, stackedValue[index] + currentValue]; + return { + values: [...acc.values, acc.values[index] + currentValue], + total: acc.total + currentValue, + }; + }, + { + values: [] as number[], + total: 0, }, - [] as number[], ); - stackedValues.set(xValue, stackArray); + const percent = stackArray.values.map((value) => { + return value / stackArray.total; + }); + stackedValues.set(xValue, { + values: stackArray.values, + percent, + total: stackArray.total, + }); }); return stackedValues; } -export function formatStackedDataSeriesValues(dataseries: RawDataSeries[], scaleToExtent: boolean): DataSeries[] { +export function formatStackedDataSeriesValues( + dataseries: RawDataSeries[], + scaleToExtent: boolean, + isPercentageMode: boolean = false, +): DataSeries[] { const yValueStackMap = getYValueStackMap(dataseries); const stackedValues = computeYStackedMapValues(yValueStackMap, scaleToExtent); @@ -55,17 +92,25 @@ export function formatStackedDataSeriesValues(dataseries: RawDataSeries[], scale const stackedDataSeries: DataSeries[] = dataseries.map((ds, seriesIndex) => { const newData: DataSeriesDatum[] = []; ds.data.forEach((data) => { - const { x, y1, datum } = data; - if (stackedValues.get(x) === undefined) { + const { x, datum } = data; + const stack = stackedValues.get(x); + if (!stack) { return; } + let y1: number | null = null; + if (isPercentageMode) { + y1 = data.y1 != null ? data.y1 / stack.total : null; + } else { + y1 = data.y1; + } + let y0 = isPercentageMode && data.y0 != null ? data.y0 / stack.total : data.y0; let computedY0: number | null; if (scaleToExtent) { - computedY0 = data.y0 ? data.y0 : y1; + computedY0 = y0 ? y0 : y1; } else { - computedY0 = data.y0 ? data.y0 : 0; + computedY0 = y0 ? y0 : 0; } - const initialY0 = data.y0 == null ? null : data.y0; + const initialY0 = y0 == null ? null : y0; if (seriesIndex === 0) { newData.push({ x, @@ -76,17 +121,20 @@ export function formatStackedDataSeriesValues(dataseries: RawDataSeries[], scale datum, }); } else { - const stack = stackedValues.get(x); - if (!stack) { - return; - } - const stackY = stack[seriesIndex]; - const stackedY1 = y1 !== null ? stackY + y1 : null; - let stackedY0: number | null = data.y0 == null ? stackY : stackY + data.y0; - // configure null y0 if y1 is null - // it's semantically right to say y0 is null if y1 is null - if (stackedY1 === null) { - stackedY0 = null; + const stackY = isPercentageMode ? stack.percent[seriesIndex] : stack.values[seriesIndex]; + let stackedY1: number | null = null; + let stackedY0: number | null = null; + if (isPercentageMode) { + stackedY1 = y1 !== null ? stackY + y1 : null; + stackedY0 = y0 != null ? stackY + y0 : stackY; + } else { + stackedY1 = y1 !== null ? stackY + y1 : null; + stackedY0 = y0 != null ? stackY + y0 : stackY; + // configure null y0 if y1 is null + // it's semantically right to say y0 is null if y1 is null + if (stackedY1 === null) { + stackedY0 = null; + } } newData.push({ x, @@ -98,6 +146,7 @@ export function formatStackedDataSeriesValues(dataseries: RawDataSeries[], scale }); } }); + return { specId: ds.specId, key: ds.key, diff --git a/src/specs/bar_series.tsx b/src/specs/bar_series.tsx index 6785ee479e..b2e85330ce 100644 --- a/src/specs/bar_series.tsx +++ b/src/specs/bar_series.tsx @@ -18,6 +18,7 @@ export class BarSeriesSpecComponent extends PureComponent { yScaleToDataExtent: false, hideInLegend: false, enableHistogramMode: false, + stackAsPercentage: false, }; componentDidMount() { const { chartStore, children, ...config } = this.props; diff --git a/src/state/utils.ts b/src/state/utils.ts index 3c6c763586..bbc4b593c6 100644 --- a/src/state/utils.ts +++ b/src/state/utils.ts @@ -122,14 +122,34 @@ export function computeSeriesDomains( const formattedDataSeries = getFormattedDataseries(specsArray, splittedSeries); // tslint:disable-next-line:no-console - // console.log({ formattedDataSeries, xDomain, yDomain }); - + // console.log({ formattedDataSeries, xDomain, yDomain });\ + const lastValues = new Map(); + + formattedDataSeries.stacked.forEach((ds) => { + ds.dataSeries.forEach((series) => { + if (series.data.length > 0) { + const last = series.data[series.data.length - 1]; + if (last !== null && last.initialY1 !== null) { + lastValues.set(series.seriesColorKey, last.initialY1); + } + } + }); + }); + const updatesSeriesColor = new Map(); + seriesColors.forEach((value, key) => { + const lastValue = lastValues.get(key); + const updatedColorSet = { + ...value, + lastValue, + }; + updatesSeriesColor.set(key, updatedColorSet); + }); return { xDomain, yDomain, splittedDataSeries, formattedDataSeries, - seriesColors, + seriesColors: updatesSeriesColor, }; } From 230c7c6a3ffea10320b39d8ec26a006d14c39763 Mon Sep 17 00:00:00 2001 From: Marco Vettorello Date: Mon, 1 Jul 2019 18:42:58 +0200 Subject: [PATCH 2/9] test: add tests for stackAsPercentage --- src/lib/series/domains/y_domain.test.ts | 105 +++++++ src/lib/series/domains/y_domain.ts | 40 ++- .../stacked_percent_series_utils.test.ts | 293 ++++++++++++++++++ 3 files changed, 417 insertions(+), 21 deletions(-) create mode 100644 src/lib/series/stacked_percent_series_utils.test.ts diff --git a/src/lib/series/domains/y_domain.test.ts b/src/lib/series/domains/y_domain.test.ts index c083110311..0f8eb71459 100644 --- a/src/lib/series/domains/y_domain.test.ts +++ b/src/lib/series/domains/y_domain.test.ts @@ -691,4 +691,109 @@ describe('Y Domain', () => { const errorMessage = 'custom yDomain for a is invalid, computed min is greater than custom max'; expect(attemptToMerge).toThrowError(errorMessage); }); + test('Should merge Y domain with stacked as percentage', () => { + const dataSeries1: RawDataSeries[] = [ + { + specId: getSpecId('a'), + key: [''], + seriesColorKey: '', + data: [{ x: 1, y1: 2 }, { x: 2, y1: 2 }, { x: 3, y1: 2 }, { x: 4, y1: 5 }], + }, + { + specId: getSpecId('a'), + key: [''], + seriesColorKey: '', + data: [{ x: 1, y1: 2 }, { x: 4, y1: 7 }], + }, + ]; + const dataSeries2: RawDataSeries[] = [ + { + specId: getSpecId('a'), + key: [''], + seriesColorKey: '', + data: [{ x: 1, y1: 10 }, { x: 2, y1: 10 }, { x: 3, y1: 2 }, { x: 4, y1: 5 }], + }, + ]; + const specDataSeries = new Map(); + specDataSeries.set(getSpecId('a'), dataSeries1); + specDataSeries.set(getSpecId('b'), dataSeries2); + const mergedDomain = mergeYDomain( + specDataSeries, + [ + { + seriesType: 'area', + yScaleType: ScaleType.Linear, + groupId: getGroupId('a'), + id: getSpecId('a'), + stackAccessors: ['a'], + yScaleToDataExtent: true, + stackAsPercentage: true, + }, + { + seriesType: 'area', + yScaleType: ScaleType.Log, + groupId: getGroupId('a'), + id: getSpecId('b'), + yScaleToDataExtent: true, + }, + ], + new Map(), + ); + expect(mergedDomain).toEqual([ + { + groupId: 'a', + domain: [0, 1], + scaleType: ScaleType.Linear, + isBandScale: false, + type: 'yDomain', + }, + ]); + }); + test('Should merge Y domain with as percentage regadless of custom domains', () => { + const groupId = getGroupId('a'); + + const dataSeries: RawDataSeries[] = [ + { + specId: getSpecId('a'), + key: [''], + seriesColorKey: '', + data: [{ x: 1, y1: 2 }, { x: 2, y1: 2 }, { x: 3, y1: 2 }, { x: 4, y1: 5 }], + }, + { + specId: getSpecId('a'), + key: [''], + seriesColorKey: '', + data: [{ x: 1, y1: 2 }, { x: 4, y1: 7 }], + }, + ]; + const specDataSeries = new Map(); + specDataSeries.set(getSpecId('a'), dataSeries); + const domainsByGroupId = new Map(); + domainsByGroupId.set(groupId, { min: 2, max: 20 }); + + const mergedDomain = mergeYDomain( + specDataSeries, + [ + { + seriesType: 'area', + yScaleType: ScaleType.Linear, + groupId, + id: getSpecId('a'), + stackAccessors: ['a'], + yScaleToDataExtent: true, + stackAsPercentage: true, + }, + ], + domainsByGroupId, + ); + expect(mergedDomain).toEqual([ + { + type: 'yDomain', + groupId, + domain: [0, 1], + scaleType: ScaleType.Linear, + isBandScale: false, + }, + ]); + }); }); diff --git a/src/lib/series/domains/y_domain.ts b/src/lib/series/domains/y_domain.ts index c63d9ea562..aa8499d048 100644 --- a/src/lib/series/domains/y_domain.ts +++ b/src/lib/series/domains/y_domain.ts @@ -43,9 +43,9 @@ export function mergeYDomain( return Boolean(spec.stackAsPercentage); }); - let groupDomain: number[]; + let domain: number[]; if (isPercentageStack) { - groupDomain = computeContinuousDataDomain([0, 1], identity); + domain = computeContinuousDataDomain([0, 1], identity); } else { // compute stacked domain const isStackedScaleToExtent = groupSpecs.stacked.some((spec) => { @@ -62,35 +62,33 @@ export function mergeYDomain( const nonStackedDomain = computeYNonStackedDomain(nonStackedDataSeries, isNonStackedScaleToExtent); // merge stacked and non stacked domain together - groupDomain = computeContinuousDataDomain( + domain = computeContinuousDataDomain( [...stackedDomain, ...nonStackedDomain], identity, isStackedScaleToExtent || isNonStackedScaleToExtent, ); - } - const [computedDomainMin, computedDomainMax] = groupDomain; - let domain = groupDomain; + const [computedDomainMin, computedDomainMax] = domain; - const customDomain = domainsByGroupId.get(groupId); + const customDomain = domainsByGroupId.get(groupId); - if (customDomain && isCompleteBound(customDomain)) { - // Don't need to check min > max because this has been validated on axis domain merge - domain = [customDomain.min, customDomain.max]; - } else if (customDomain && isLowerBound(customDomain)) { - if (customDomain.min > computedDomainMax) { - throw new Error(`custom yDomain for ${groupId} is invalid, custom min is greater than computed max`); - } + if (customDomain && isCompleteBound(customDomain)) { + // Don't need to check min > max because this has been validated on axis domain merge + domain = [customDomain.min, customDomain.max]; + } else if (customDomain && isLowerBound(customDomain)) { + if (customDomain.min > computedDomainMax) { + throw new Error(`custom yDomain for ${groupId} is invalid, custom min is greater than computed max`); + } - domain = [customDomain.min, computedDomainMax]; - } else if (customDomain && isUpperBound(customDomain)) { - if (computedDomainMin > customDomain.max) { - throw new Error(`custom yDomain for ${groupId} is invalid, computed min is greater than custom max`); - } + domain = [customDomain.min, computedDomainMax]; + } else if (customDomain && isUpperBound(customDomain)) { + if (computedDomainMin > customDomain.max) { + throw new Error(`custom yDomain for ${groupId} is invalid, computed min is greater than custom max`); + } - domain = [computedDomainMin, customDomain.max]; + domain = [computedDomainMin, customDomain.max]; + } } - return { type: 'yDomain', isBandScale: false, diff --git a/src/lib/series/stacked_percent_series_utils.test.ts b/src/lib/series/stacked_percent_series_utils.test.ts new file mode 100644 index 0000000000..4d639d2226 --- /dev/null +++ b/src/lib/series/stacked_percent_series_utils.test.ts @@ -0,0 +1,293 @@ +import { getSpecId } from '../utils/ids'; +import { RawDataSeries } from './series'; +import { formatStackedDataSeriesValues } from './stacked_series_utils'; + +describe('Stacked Series Utils', () => { + const STANDARD_DATA_SET: RawDataSeries[] = [ + { + data: [ + { + x: 0, + y1: 10, + }, + ], + key: [], + seriesColorKey: 'color-key', + specId: getSpecId('spec1'), + }, + { + data: [ + { + x: 0, + y1: 20, + }, + ], + key: [], + seriesColorKey: 'color-key', + specId: getSpecId('spec2'), + }, + { + data: [ + { + x: 0, + y1: 70, + }, + ], + key: [], + seriesColorKey: 'color-key', + specId: getSpecId('spec3'), + }, + ]; + const WITH_NULL_DATASET: RawDataSeries[] = [ + { + data: [ + { + x: 0, + y1: 10, + }, + ], + key: [], + seriesColorKey: 'color-key', + specId: getSpecId('spec1'), + }, + { + data: [ + { + x: 0, + y1: null, + }, + ], + key: [], + seriesColorKey: 'color-key', + specId: getSpecId('spec2'), + }, + { + data: [ + { + x: 0, + y1: 30, + }, + ], + key: [], + seriesColorKey: 'color-key', + specId: getSpecId('spec3'), + }, + ]; + const STANDARD_DATA_SET_WY0: RawDataSeries[] = [ + { + data: [ + { + x: 0, + y0: 2, + y1: 10, + }, + ], + key: [], + seriesColorKey: 'color-key', + specId: getSpecId('spec1'), + }, + { + data: [ + { + x: 0, + y0: 4, + y1: 20, + }, + ], + key: [], + seriesColorKey: 'color-key', + specId: getSpecId('spec2'), + }, + { + data: [ + { + x: 0, + y0: 6, + y1: 70, + }, + ], + key: [], + seriesColorKey: 'color-key', + specId: getSpecId('spec3'), + }, + ]; + const WITH_NULL_DATASET_WY0: RawDataSeries[] = [ + { + data: [ + { + x: 0, + y0: 2, + y1: 10, + }, + ], + key: [], + seriesColorKey: 'color-key', + specId: getSpecId('spec1'), + }, + { + data: [ + { + x: 0, + y1: null, + }, + ], + key: [], + seriesColorKey: 'color-key', + specId: getSpecId('spec2'), + }, + { + data: [ + { + x: 0, + y0: 6, + y1: 90, + }, + ], + key: [], + seriesColorKey: 'color-key', + specId: getSpecId('spec3'), + }, + ]; + const DATA_SET_WITH_NULL_2: RawDataSeries[] = [ + { + specId: getSpecId('spec1'), + key: ['a'], + seriesColorKey: 'a', + data: [{ x: 1, y1: 10 }, { x: 2, y1: 20 }, { x: 4, y1: 40 }], + }, + { + specId: getSpecId('spec1'), + key: ['b'], + seriesColorKey: 'b', + data: [{ x: 1, y1: 90 }, { x: 3, y1: 30 }], + }, + ]; + + describe.only('Format stacked dataset', () => { + test('format data without nulls', () => { + const formattedData = formatStackedDataSeriesValues(STANDARD_DATA_SET, false, true); + const data0 = formattedData[0].data[0]; + expect(data0.initialY1).toBe(0.1); + expect(data0.y0).toBe(0); + expect(data0.y1).toBe(0.1); + + const data1 = formattedData[1].data[0]; + expect(data1.initialY1).toBe(0.2); + expect(data1.y0).toBe(0.1); + expect(data1.y1).toBeCloseTo(0.3); + + const data2 = formattedData[2].data[0]; + expect(data2.initialY1).toBe(0.7); + expect(data2.y0).toBe(0.3); + expect(data2.y1).toBe(1); + }); + test('format data with nulls', () => { + const formattedData = formatStackedDataSeriesValues(WITH_NULL_DATASET, false, true); + const data0 = formattedData[0].data[0]; + expect(data0.initialY1).toBe(0.25); + expect(data0.y0).toBe(0); + expect(data0.y1).toBe(0.25); + + expect(formattedData[1].data[0]).toEqual({ + datum: undefined, + initialY0: null, + initialY1: null, + x: 0, + y1: null, + y0: 0.25, + }); + + const data2 = formattedData[2].data[0]; + expect(data2.initialY1).toBe(0.75); + expect(data2.y0).toBe(0.25); + expect(data2.y1).toBe(1); + }); + test('format data without nulls with y0 values', () => { + const formattedData = formatStackedDataSeriesValues(STANDARD_DATA_SET_WY0, false, true); + const data0 = formattedData[0].data[0]; + expect(data0.initialY0).toBe(0.02); + expect(data0.initialY1).toBe(0.1); + expect(data0.y0).toBe(0.02); + expect(data0.y1).toBe(0.1); + + const data1 = formattedData[1].data[0]; + expect(data1.initialY0).toBe(0.04); + expect(data1.initialY1).toBe(0.2); + expect(data1.y0).toBe(0.14); + expect(data1.y1).toBeCloseTo(0.3, 5); + + const data2 = formattedData[2].data[0]; + expect(data2.initialY0).toBe(0.06); + expect(data2.initialY1).toBe(0.7); + expect(data2.y0).toBe(0.36); + expect(data2.y1).toBe(1); + }); + test('format data with nulls', () => { + const formattedData = formatStackedDataSeriesValues(WITH_NULL_DATASET_WY0, false, true); + const data0 = formattedData[0].data[0]; + expect(data0.initialY0).toBe(0.02); + expect(data0.initialY1).toBe(0.1); + expect(data0.y0).toBe(0.02); + expect(data0.y1).toBe(0.1); + + const data1 = formattedData[1].data[0]; + expect(data1.initialY0).toBe(null); + expect(data1.initialY1).toBe(null); + expect(data1.y0).toBe(0.1); + expect(data1.y1).toBe(null); + + const data2 = formattedData[2].data[0]; + expect(data2.initialY0).toBe(0.06); + expect(data2.initialY1).toBe(0.9); + expect(data2.y0).toBe(0.16); + expect(data2.y1).toBe(1); + }); + test('format data without nulls on second series', () => { + const formattedData = formatStackedDataSeriesValues(DATA_SET_WITH_NULL_2, false, true); + expect(formattedData.length).toBe(2); + expect(formattedData[0].data.length).toBe(3); + expect(formattedData[1].data.length).toBe(2); + + expect(formattedData[0].data[0]).toEqual({ + datum: undefined, + initialY0: null, + initialY1: 0.1, + x: 1, + y0: 0, + y1: 0.1, + }); + expect(formattedData[0].data[1]).toEqual({ + datum: undefined, + initialY0: null, + initialY1: 1, + x: 2, + y0: 0, + y1: 1, + }); + expect(formattedData[0].data[2]).toEqual({ + datum: undefined, + initialY0: null, + initialY1: 1, + x: 4, + y0: 0, + y1: 1, + }); + expect(formattedData[1].data[0]).toEqual({ + datum: undefined, + initialY0: null, + initialY1: 0.9, + x: 1, + y0: 0.1, + y1: 1, + }); + expect(formattedData[1].data[1]).toEqual({ + datum: undefined, + initialY0: null, + initialY1: 1, + x: 3, + y0: 0, + y1: 1, + }); + }); + }); +}); From aa6f43507b5e145112cf13eb5f82b177af86f7a6 Mon Sep 17 00:00:00 2001 From: Marco Vettorello Date: Mon, 1 Jul 2019 19:38:09 +0200 Subject: [PATCH 3/9] chore(storybook): add story for stacked as percentage --- stories/bar_chart.tsx | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/stories/bar_chart.tsx b/stories/bar_chart.tsx index c7f5700431..45c87b3902 100644 --- a/stories/bar_chart.tsx +++ b/stories/bar_chart.tsx @@ -475,6 +475,43 @@ storiesOf('Bar Chart', module) ); }) + .add('stacked as percentage', () => { + const stackedAsPercentage = boolean('stacked as percentage', true); + const clusterBars = boolean('cluster', true); + return ( + + + + (stackedAsPercentage && clusterBars ? `${Number(d * 100).toFixed(0)} %` : d)} + /> + + + + ); + }) .add('clustered with axis and legend', () => { const chartRotation = select( 'chartRotation', From e73de20bc1cda33e515c7d36fe2a8c6131db1b7e Mon Sep 17 00:00:00 2001 From: Marco Vettorello Date: Wed, 3 Jul 2019 15:44:44 +0200 Subject: [PATCH 4/9] fix(story): stackAccessor empty on clusterBar option on --- stories/bar_chart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stories/bar_chart.tsx b/stories/bar_chart.tsx index 45c87b3902..64095bd411 100644 --- a/stories/bar_chart.tsx +++ b/stories/bar_chart.tsx @@ -495,7 +495,7 @@ storiesOf('Bar Chart', module) yScaleType={ScaleType.Linear} xAccessor="x" yAccessors={['y']} - stackAccessors={clusterBars ? ['x'] : []} + stackAccessors={clusterBars ? [] : ['x']} stackAsPercentage={stackedAsPercentage} splitSeriesAccessors={['g']} data={[ From ef987e6ca8546597bb797c0a667103fef9262b10 Mon Sep 17 00:00:00 2001 From: Marco Vettorello Date: Wed, 3 Jul 2019 15:49:54 +0200 Subject: [PATCH 5/9] refactor(stacked_series): add StackedValues type --- src/lib/series/stacked_series_utils.ts | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/src/lib/series/stacked_series_utils.ts b/src/lib/series/stacked_series_utils.ts index 86ae75c233..d2da96382d 100644 --- a/src/lib/series/stacked_series_utils.ts +++ b/src/lib/series/stacked_series_utils.ts @@ -1,5 +1,11 @@ import { DataSeries, DataSeriesDatum, RawDataSeries } from './series'; +interface StackedValues { + values: number[]; + percent: number[]; + total: number; +} + /** * Map each y value from a RawDataSeries on it's specific x value into, * ordering the stack based on the dataseries index. @@ -26,22 +32,8 @@ export function getYValueStackMap(dataseries: RawDataSeries[]): Map, scaleToExtent: boolean, -): Map< - any, - { - values: number[]; - percent: number[]; - total: number; - } -> { - const stackedValues = new Map< - any, - { - values: number[]; - percent: number[]; - total: number; - } - >(); +): Map { + const stackedValues = new Map(); yValueStackMap.forEach((yStackArray, xValue) => { const stackArray = yStackArray.reduce( From 7dd23ffe15ffe91a3f07b561bcd35e643ab8ba44 Mon Sep 17 00:00:00 2001 From: Marco Vettorello Date: Wed, 3 Jul 2019 15:51:50 +0200 Subject: [PATCH 6/9] refactor(utils): rename updatesSeriesColor map name --- src/state/utils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/state/utils.ts b/src/state/utils.ts index bbc4b593c6..b296d4b9e5 100644 --- a/src/state/utils.ts +++ b/src/state/utils.ts @@ -135,21 +135,21 @@ export function computeSeriesDomains( } }); }); - const updatesSeriesColor = new Map(); + const updatedSeriesColors = new Map(); seriesColors.forEach((value, key) => { const lastValue = lastValues.get(key); const updatedColorSet = { ...value, lastValue, }; - updatesSeriesColor.set(key, updatedColorSet); + updatedSeriesColors.set(key, updatedColorSet); }); return { xDomain, yDomain, splittedDataSeries, formattedDataSeries, - seriesColors: updatesSeriesColor, + seriesColors: updatedSeriesColors, }; } From 501289cd80bf4cc1f805aad09b7547e68e7de6d5 Mon Sep 17 00:00:00 2001 From: Marco Vettorello Date: Wed, 3 Jul 2019 15:59:23 +0200 Subject: [PATCH 7/9] refactor(percentage_stack): check stackAsPercentage when split series --- src/lib/series/domains/y_domain.ts | 13 +++++++++---- src/lib/series/series.ts | 4 +--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/lib/series/domains/y_domain.ts b/src/lib/series/domains/y_domain.ts index aa8499d048..546d87b9f0 100644 --- a/src/lib/series/domains/y_domain.ts +++ b/src/lib/series/domains/y_domain.ts @@ -39,9 +39,7 @@ export function mergeYDomain( const yDomains = specsByGroupIdsEntries.map( ([groupId, groupSpecs]): YDomain => { const groupYScaleType = coerceYScaleTypes([...groupSpecs.stacked, ...groupSpecs.nonStacked]); - const isPercentageStack = groupSpecs.stacked.some((spec) => { - return Boolean(spec.stackAsPercentage); - }); + const { isPercentageStack } = groupSpecs; let domain: number[]; if (isPercentageStack) { @@ -152,10 +150,14 @@ function computeYNonStackedDomain(dataseries: RawDataSeries[], scaleToExtent: bo return computeContinuousDataDomain([...yValues.values()], identity, scaleToExtent); } export function splitSpecsByGroupId(specs: YBasicSeriesSpec[]) { - const specsByGroupIds = new Map(); + const specsByGroupIds = new Map< + GroupId, + { isPercentageStack: boolean; stacked: YBasicSeriesSpec[]; nonStacked: YBasicSeriesSpec[] } + >(); // split each specs by groupId and by stacked or not specs.forEach((spec) => { const group = specsByGroupIds.get(spec.groupId) || { + isPercentageStack: false, stacked: [], nonStacked: [], }; @@ -164,6 +166,9 @@ export function splitSpecsByGroupId(specs: YBasicSeriesSpec[]) { } else { group.nonStacked.push(spec); } + if (spec.stackAsPercentage === true) { + group.isPercentageStack = true; + } specsByGroupIds.set(spec.groupId, group); }); return specsByGroupIds; diff --git a/src/lib/series/series.ts b/src/lib/series/series.ts index 0c5d27c136..6f6d6da3ea 100644 --- a/src/lib/series/series.ts +++ b/src/lib/series/series.ts @@ -219,9 +219,7 @@ export function getFormattedDataseries( }[] = []; specsByGroupIdsEntries.forEach(([groupId, groupSpecs]) => { - const isPercentageStack = groupSpecs.stacked.some((spec) => { - return Boolean(spec.stackAsPercentage); - }); + const { isPercentageStack } = groupSpecs; // format stacked data series const stackedDataSeries = getRawDataSeries(groupSpecs.stacked, dataSeries); const stackedDataSeriesValues = formatStackedDataSeriesValues( From 32bd14aa5020df8c49cb5d350bbb237b677f4449 Mon Sep 17 00:00:00 2001 From: Marco Vettorello Date: Thu, 4 Jul 2019 12:34:48 +0200 Subject: [PATCH 8/9] chore: add stacked area story --- stories/area_chart.tsx | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/stories/area_chart.tsx b/stories/area_chart.tsx index 9280e6e88d..706805a4af 100644 --- a/stories/area_chart.tsx +++ b/stories/area_chart.tsx @@ -228,6 +228,42 @@ storiesOf('Area Chart', module) ); }) + .add('stacked as percentage', () => { + const stackedAsPercentage = boolean('stacked as percentage', true); + return ( + + + + `${Number(d * 100).toFixed(0)} %`} + /> + + + + ); + }) .add('stacked with separated specs', () => { return ( From 3093ec943662671e0ba8e9b55f95c4435ee0006d Mon Sep 17 00:00:00 2001 From: Marco Vettorello Date: Thu, 4 Jul 2019 16:04:11 +0200 Subject: [PATCH 9/9] chore: allows stackAsPercentage only for areas and bars --- src/lib/series/domains/y_domain.ts | 11 ++--------- src/lib/series/specs.ts | 12 ++++++++---- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/lib/series/domains/y_domain.ts b/src/lib/series/domains/y_domain.ts index 546d87b9f0..a0d87175af 100644 --- a/src/lib/series/domains/y_domain.ts +++ b/src/lib/series/domains/y_domain.ts @@ -16,15 +16,8 @@ export type YDomain = BaseDomain & { }; export type YBasicSeriesSpec = Pick< BasicSeriesSpec, - | 'id' - | 'seriesType' - | 'yScaleType' - | 'groupId' - | 'stackAccessors' - | 'yScaleToDataExtent' - | 'colorAccessors' - | 'stackAsPercentage' ->; + 'id' | 'seriesType' | 'yScaleType' | 'groupId' | 'stackAccessors' | 'yScaleToDataExtent' | 'colorAccessors' +> & { stackAsPercentage?: boolean }; export function mergeYDomain( dataSeries: Map, diff --git a/src/lib/series/specs.ts b/src/lib/series/specs.ts index 5d7cf5e688..5a88632944 100644 --- a/src/lib/series/specs.ts +++ b/src/lib/series/specs.ts @@ -96,10 +96,6 @@ export interface SeriesAccessors { splitSeriesAccessors?: Accessor[]; /** An array of fields thats indicates the stack membership */ stackAccessors?: Accessor[]; - /** - * Stack each series in percentage for each point. - */ - stackAsPercentage?: boolean; /** An optional array of field name thats indicates the stack membership */ colorAccessors?: Accessor[]; } @@ -140,6 +136,10 @@ export type BarSeriesSpec = BasicSeriesSpec & { /** If true, will stack all BarSeries and align bars to ticks (instead of centered on ticks) */ enableHistogramMode?: boolean; barSeriesStyle?: CustomBarSeriesStyle; + /** + * Stack each series in percentage for each point. + */ + stackAsPercentage?: boolean; }; /** @@ -171,6 +171,10 @@ export type AreaSeriesSpec = BasicSeriesSpec & /** The type of interpolator to be used to interpolate values between points */ curve?: CurveType; areaSeriesStyle?: AreaSeriesStyle; + /** + * Stack each series in percentage for each point. + */ + stackAsPercentage?: boolean; }; interface HistogramConfig {