Skip to content

Commit 3e52380

Browse files
authored
feat(Bar/Column/ComposedChart): add stack aggregate total labels and tooltip support (#8302)
Closes #3420
1 parent b5bd887 commit 3e52380

27 files changed

Lines changed: 616 additions & 20 deletions

.github/workflows/test.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ jobs:
8080
- name: Install
8181
run: yarn install --immutable
8282

83+
- name: Override react-is for charts
84+
if: ${{ matrix.react == '18' && matrix.spec == 'charts' }}
85+
run: jq '.resolutions["react-is"] = "18"' package.json > tmp.json && mv tmp.json package.json
86+
8387
- name: Install 18
8488
if: ${{ matrix.react == '18' }}
8589
run: |

.storybook/preview-head.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,11 @@
191191
align-items: flex-start;
192192
}
193193

194+
/* Stacked chart tooltip total row */
195+
.stackedTooltipWithTotal .recharts-tooltip-item:last-child {
196+
font-weight: bold;
197+
}
198+
194199
/* TODO remove this workaround as soon as https://github.com/storybookjs/storybook/issues/20497 is fixed */
195200
.docs-story > div > div[scale] {
196201
min-height: 20px;

cypress/support/utils.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,27 @@ export function testChartLegendConfig(Component, props) {
100100
cy.findAllByTestId('catval').should('be.visible');
101101
});
102102
}
103+
104+
export function testStackAggregateTotals(Component, props) {
105+
it('showStackAggregateTotals', () => {
106+
const { dataset, measures } = props;
107+
const stackAccessors = measures.filter((measure) => measure.stackId != null).map((measure) => measure.accessor);
108+
const expectedTotals: number[] = dataset.map((entry) =>
109+
stackAccessors.reduce((sum, accessor) => sum + (Number(entry[accessor]) || 0), 0),
110+
);
111+
112+
cy.mount(<Component {...props} chartConfig={{ showStackAggregateTotals: true }} />);
113+
114+
expectedTotals.forEach((total) => {
115+
cy.get('.recharts-label').contains(total).closest('text').should('have.attr', 'font-weight', 'bold');
116+
});
117+
118+
// tooltip
119+
cy.get('.recharts-wrapper').trigger('mousemove', 'center', { force: true });
120+
cy.get('.recharts-tooltip-item').last().should('contain.text', 'Total : 560').and('have.css', 'font-weight', '700');
121+
122+
cy.mount(<Component {...props} chartConfig={{ showStackAggregateTotals: false }} />);
123+
cy.get('.recharts-bar-rectangles').should('exist');
124+
cy.get('text[font-weight="bold"]').should('not.exist');
125+
});
126+
}

packages/charts/src/components/BarChart/BarChart.cy.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { complexDataSet } from '../../resources/DemoProps.js';
22
import { BarChart } from './index.js';
3-
import { cypressPassThroughTestsFactory, testChartLegendConfig, testChartZoomingTool } from '@/cypress/support/utils';
3+
import {
4+
cypressPassThroughTestsFactory,
5+
testChartLegendConfig,
6+
testChartZoomingTool,
7+
testStackAggregateTotals,
8+
} from '@/cypress/support/utils';
49

510
const dimensions = [
611
{
@@ -93,4 +98,13 @@ describe('BarChart', () => {
9398
testChartZoomingTool(BarChart, { dataset: complexDataSet, dimensions, measures });
9499

95100
cypressPassThroughTestsFactory(BarChart, { dimensions: [], measures: [] });
101+
102+
testStackAggregateTotals(BarChart, {
103+
dataset: complexDataSet.slice(0, 3),
104+
dimensions,
105+
measures: [
106+
{ accessor: 'users', stackId: 'A', label: 'Users' },
107+
{ accessor: 'sessions', stackId: 'A', label: 'Active Sessions' },
108+
],
109+
});
96110
});

packages/charts/src/components/BarChart/BarChart.mdx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { Canvas, Meta } from '@storybook/addon-docs/blocks';
33
import TooltipStory from '../../resources/TooltipConfig.mdx';
44
import LegendStory from '../../resources/LegendConfig.mdx';
55
import NormalizedStackedChartStory from '../../resources/NormalizedStackedChart.mdx';
6+
import StackAggregateTotalsStory from '../../resources/StackAggregateTotals.mdx';
7+
import CustomTooltipTotalStory from '../../resources/CustomTooltipTotal.mdx';
68
import * as ComponentStories from './BarChart.stories';
79

810
<Meta of={ComponentStories} />
@@ -67,6 +69,16 @@ You can set a reference line to any value by using the `referenceLine` `chartCon
6769

6870
<Canvas of={ComponentStories.WithHighlightedMeasure} />
6971

72+
<StackAggregateTotalsStory
73+
of={ComponentStories.WithStackAggregateTotalsAndTooltip}
74+
description={<>You can display a total label at the end of each stacked bar group by setting <code>chartConfig.showStackAggregateTotals</code> to <code>true</code>. The tooltip includes the total automatically when only a single bar per dimension is present.</>}
75+
/>
76+
77+
<CustomTooltipTotalStory
78+
of={ComponentStories.WithCustomTooltipTotal}
79+
description={<>When multiple bars per dimension are present (e.g. stacked + standalone), the built-in tooltip total is not available. You can provide a custom tooltip via the <code>tooltipConfig.content</code> prop to display a total for specific measures.</>}
80+
/>
81+
7082
<TooltipStory of={ComponentStories.WithCustomTooltipConfig} />
7183

7284
<LegendStory of={ComponentStories.WithCustomLegendConfig} />

packages/charts/src/components/BarChart/BarChart.stories.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Meta, StoryObj } from '@storybook/react-vite';
22
import {
33
complexDataSet,
4+
CustomTooltipContent,
45
legendConfig,
56
secondaryDimensionDataSet,
67
simpleDataSet,
@@ -165,6 +166,56 @@ export const WithNormalizedStacks: Story = {
165166
args: stackedNormalizedConfig,
166167
};
167168

169+
export const WithStackAggregateTotalsAndTooltip: Story = {
170+
name: 'With Stack Aggregate Totals',
171+
args: {
172+
dataset: complexDataSet.slice(0, 3),
173+
measures: [
174+
{
175+
accessor: 'users',
176+
stackId: 'A',
177+
label: 'Users',
178+
},
179+
{
180+
accessor: 'sessions',
181+
stackId: 'A',
182+
label: 'Active Sessions',
183+
},
184+
],
185+
chartConfig: {
186+
showStackAggregateTotals: true,
187+
},
188+
},
189+
};
190+
191+
export const WithCustomTooltipTotal: Story = {
192+
args: {
193+
dataset: complexDataSet.slice(0, 5),
194+
measures: [
195+
{
196+
accessor: 'users',
197+
stackId: 'A',
198+
label: 'Users',
199+
},
200+
{
201+
accessor: 'sessions',
202+
stackId: 'A',
203+
label: 'Active Sessions',
204+
},
205+
{
206+
accessor: 'volume',
207+
label: 'Vol.',
208+
},
209+
],
210+
chartConfig: {
211+
showStackAggregateTotals: true,
212+
},
213+
tooltipConfig: {
214+
content: <CustomTooltipContent />,
215+
},
216+
},
217+
};
218+
168219
export const WithCustomTooltipConfig: Story = {
169220
args: tooltipConfig,
170221
};

packages/charts/src/components/BarChart/index.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import type { IChartMeasure } from '../../interfaces/IChartMeasure.js';
3333
import { ChartContainer } from '../../internal/ChartContainer.js';
3434
import { ChartDataLabel } from '../../internal/ChartDataLabel.js';
3535
import { defaultFormatter } from '../../internal/defaults.js';
36+
import { StackAggregateLabel } from '../../internal/StackAggregateLabel.js';
37+
import { StackedTooltipContent } from '../../internal/StackedTooltipContent.js';
3638
import { brushProps, tickLineConfig, tooltipContentStyle, tooltipFillOpacity } from '../../internal/staticProps.js';
3739
import { getCellColors, resolvePrimaryAndSecondaryMeasures } from '../../internal/Utils.js';
3840
import { XAxisTicks } from '../../internal/XAxisTicks.js';
@@ -168,11 +170,12 @@ const BarChart = forwardRef<HTMLDivElement, BarChartProps>((props, ref) => {
168170
};
169171
const referenceLine = chartConfig.referenceLine;
170172

171-
const { dimensions, measures } = usePrepareDimensionsAndMeasures(
173+
const { dimensions, measures, stackGroups, lastInStack } = usePrepareDimensionsAndMeasures(
172174
props.dimensions,
173175
props.measures,
174176
dimensionDefaults,
175177
measureDefaults,
178+
chartConfig.showStackAggregateTotals,
176179
);
177180

178181
const tooltipValueFormatter = useTooltipFormatter(measures);
@@ -224,6 +227,10 @@ const BarChart = forwardRef<HTMLDivElement, BarChartProps>((props, ref) => {
224227

225228
const { isMounted, handleBarAnimationStart, handleBarAnimationEnd } = useCancelAnimationFallback(noAnimation);
226229

230+
const stackGroupKeys = Object.keys(stackGroups);
231+
const showStackTotalInTooltip =
232+
chartConfig.showStackAggregateTotals && stackGroupKeys.length === 1 && measures.every((m) => m.stackId != null);
233+
227234
const { chartConfig: _0, dimensions: _1, measures: _2, ...propsWithoutOmitted } = rest;
228235
return (
229236
<ChartContainer
@@ -337,6 +344,17 @@ const BarChart = forwardRef<HTMLDivElement, BarChartProps>((props, ref) => {
337344
valueAccessor={valueAccessor(element.accessor)}
338345
content={<ChartDataLabel config={element} chartType="bar" position={'insideRight'} />}
339346
/>
347+
{chartConfig.showStackAggregateTotals &&
348+
element.stackId &&
349+
typeof element.accessor === 'string' &&
350+
lastInStack.has(element.accessor) && (
351+
<LabelList
352+
data={dataset}
353+
valueAccessor={valueAccessor(element.accessor)}
354+
position="right"
355+
content={<StackAggregateLabel stackAccessors={stackGroups[element.stackId]} dataset={dataset} />}
356+
/>
357+
)}
340358
{dataset.map((data, i) => {
341359
return (
342360
<Cell
@@ -374,6 +392,14 @@ const BarChart = forwardRef<HTMLDivElement, BarChartProps>((props, ref) => {
374392
contentStyle={tooltipContentStyle}
375393
labelFormatter={tooltipLabelFormatter}
376394
{...tooltipConfig}
395+
{...(showStackTotalInTooltip && {
396+
content: (
397+
<StackedTooltipContent
398+
stackAccessors={stackGroups[stackGroupKeys[0]]}
399+
totalFormatter={chartConfig.stackAggregateTotalFormatter}
400+
/>
401+
),
402+
})}
377403
/>
378404
)}
379405
{!!chartConfig.zoomingTool && (

packages/charts/src/components/ColumnChart/ColumnChart.cy.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { complexDataSet } from '../../resources/DemoProps.js';
22
import { ColumnChart } from './index.js';
3-
import { cypressPassThroughTestsFactory, testChartLegendConfig, testChartZoomingTool } from '@/cypress/support/utils';
3+
import {
4+
cypressPassThroughTestsFactory,
5+
testChartLegendConfig,
6+
testChartZoomingTool,
7+
testStackAggregateTotals,
8+
} from '@/cypress/support/utils';
49

510
const dimensions = [
611
{
@@ -83,4 +88,13 @@ describe('ColumnChart', () => {
8388
testChartLegendConfig(ColumnChart, { dataset: complexDataSet, dimensions, measures });
8489

8590
cypressPassThroughTestsFactory(ColumnChart, { dimensions: [], measures: [] });
91+
92+
testStackAggregateTotals(ColumnChart, {
93+
dataset: complexDataSet.slice(0, 3),
94+
dimensions,
95+
measures: [
96+
{ accessor: 'users', stackId: 'A', label: 'Users' },
97+
{ accessor: 'sessions', stackId: 'A', label: 'Active Sessions' },
98+
],
99+
});
86100
});

packages/charts/src/components/ColumnChart/ColumnChart.mdx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { Canvas, Meta } from '@storybook/addon-docs/blocks';
33
import TooltipStory from '../../resources/TooltipConfig.mdx';
44
import LegendStory from '../../resources/LegendConfig.mdx';
55
import NormalizedStackedChartStory from '../../resources/NormalizedStackedChart.mdx';
6+
import StackAggregateTotalsStory from '../../resources/StackAggregateTotals.mdx';
7+
import CustomTooltipTotalStory from '../../resources/CustomTooltipTotal.mdx';
68
import * as ComponentStories from './ColumnChart.stories';
79

810
<Meta of={ComponentStories} />
@@ -62,10 +64,20 @@ You can set a reference line to any value by using the `referenceLine` `chartCon
6264

6365
<Canvas of={ComponentStories.WithReferenceLine} />
6466

65-
## With Highlighted Measures
67+
### With Highlighted Measures
6668

6769
<Canvas of={ComponentStories.WithHighlightedMeasure} />
6870

71+
<StackAggregateTotalsStory
72+
of={ComponentStories.WithStackAggregateTotals}
73+
description={<>You can display a total label at the top of each stacked column group by setting <code>chartConfig.showStackAggregateTotals</code> to <code>true</code>. The tooltip includes the total automatically when only a single column per dimension is present.</>}
74+
/>
75+
76+
<CustomTooltipTotalStory
77+
of={ComponentStories.WithCustomTooltipTotal}
78+
description={<>When multiple columns per dimension are present (e.g. stacked + standalone), the built-in tooltip total is not available. You can provide a custom tooltip via the <code>tooltipConfig.content</code> prop to display a total for specific measures.</>}
79+
/>
80+
6981
<TooltipStory of={ComponentStories.WithCustomTooltipConfig} />;
7082

7183
<LegendStory of={ComponentStories.WithCustomLegendConfig} />

packages/charts/src/components/ColumnChart/ColumnChart.stories.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Meta, StoryObj } from '@storybook/react-vite';
22
import {
33
complexDataSet,
4+
CustomTooltipContent,
45
legendConfig,
56
secondaryDimensionDataSet,
67
simpleDataSet,
@@ -156,6 +157,57 @@ export const WithHighlightedMeasure: Story = {
156157
},
157158
};
158159

160+
export const WithStackAggregateTotals: Story = {
161+
args: {
162+
dataset: complexDataSet.slice(0, 3),
163+
dimensions: [{ accessor: 'name' }],
164+
measures: [
165+
{
166+
accessor: 'users',
167+
stackId: 'A',
168+
label: 'Users',
169+
},
170+
{
171+
accessor: 'sessions',
172+
stackId: 'A',
173+
label: 'Active Sessions',
174+
},
175+
],
176+
chartConfig: {
177+
showStackAggregateTotals: true,
178+
},
179+
},
180+
};
181+
182+
export const WithCustomTooltipTotal: Story = {
183+
args: {
184+
dataset: complexDataSet.slice(0, 5),
185+
dimensions: [{ accessor: 'name' }],
186+
measures: [
187+
{
188+
accessor: 'users',
189+
stackId: 'A',
190+
label: 'Users',
191+
},
192+
{
193+
accessor: 'sessions',
194+
stackId: 'A',
195+
label: 'Active Sessions',
196+
},
197+
{
198+
accessor: 'volume',
199+
label: 'Vol.',
200+
},
201+
],
202+
chartConfig: {
203+
showStackAggregateTotals: true,
204+
},
205+
tooltipConfig: {
206+
content: <CustomTooltipContent />,
207+
},
208+
},
209+
};
210+
159211
export const WithCustomTooltipConfig: Story = {
160212
args: tooltipConfig,
161213
};

0 commit comments

Comments
 (0)