Skip to content

Commit bf00e5d

Browse files
author
Roman Snapko
authored
Add ComboChart component with Storybook documentation (#2146)
<!-- Ensure the title clearly reflects what was changed. Provide a clear and concise description of the changes made. The PR should only contain the changes related to the issue, and no other unrelated changes. --> Part of OPS-3910
1 parent c32ca95 commit bf00e5d

3 files changed

Lines changed: 341 additions & 0 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
3+
import { ComboChart } from '@/ui/chart';
4+
import { ThemeAwareDecorator } from '../../../.storybook/decorators';
5+
6+
/**
7+
* A combination bar and line chart component built on top of Recharts primitives.
8+
* Bars are plotted against the left Y-axis, lines against the right Y-axis,
9+
* and both share a unified bottom X-axis.
10+
*/
11+
const meta: Meta<typeof ComboChart> = {
12+
title: 'ui/Charts/ComboChart',
13+
component: ComboChart,
14+
tags: ['autodocs'],
15+
parameters: {
16+
layout: 'centered',
17+
},
18+
args: {
19+
className: 'h-[227px] min-w-[770px]',
20+
xAxisKey: 'week',
21+
showXAxis: true,
22+
showLeftYAxis: false,
23+
showRightYAxis: false,
24+
showTooltip: true,
25+
showLegend: false,
26+
barSize: 12,
27+
leftYAxisTickFormatter: (value: number) => `${value}`,
28+
rightYAxisTickFormatter: (value: number) =>
29+
value === 0 ? '0' : `$${value / 1000}K`,
30+
bars: [
31+
{
32+
dataKey: 'savings',
33+
radius: 4,
34+
},
35+
],
36+
lines: [
37+
{ dataKey: 'idealBurndown', strokeWidth: 4 },
38+
{ dataKey: 'remainingSavings', strokeWidth: 4, type: 'linear' },
39+
{ dataKey: 'remainingRecommendations', strokeWidth: 4, type: 'linear' },
40+
],
41+
},
42+
decorators: [ThemeAwareDecorator],
43+
} satisfies Meta<typeof ComboChart>;
44+
45+
export default meta;
46+
47+
type Story = StoryObj<typeof meta>;
48+
49+
const defaultData = [
50+
{
51+
week: 'Week 1',
52+
savings: 40,
53+
idealBurndown: 250000,
54+
remainingSavings: 220000,
55+
remainingRecommendations: 200000,
56+
},
57+
{
58+
week: 'Week 2',
59+
savings: 80,
60+
idealBurndown: 214000,
61+
remainingSavings: 205000,
62+
remainingRecommendations: 185000,
63+
},
64+
{
65+
week: 'Week 3',
66+
savings: 60,
67+
idealBurndown: 178000,
68+
remainingSavings: 175000,
69+
remainingRecommendations: 145000,
70+
},
71+
{
72+
week: 'Week 4',
73+
savings: 120,
74+
idealBurndown: 143000,
75+
remainingSavings: 145000,
76+
remainingRecommendations: 160000,
77+
},
78+
{
79+
week: 'Week 5',
80+
savings: 90,
81+
idealBurndown: 107000,
82+
remainingSavings: 120000,
83+
remainingRecommendations: 105000,
84+
},
85+
{
86+
week: 'Week 6',
87+
savings: 50,
88+
idealBurndown: 71000,
89+
remainingSavings: 95000,
90+
remainingRecommendations: 75000,
91+
},
92+
{
93+
week: 'Week 7',
94+
savings: 140,
95+
idealBurndown: 36000,
96+
remainingSavings: 55000,
97+
remainingRecommendations: 50000,
98+
},
99+
{
100+
week: 'Week 8',
101+
savings: 100,
102+
idealBurndown: 0,
103+
remainingSavings: 30000,
104+
remainingRecommendations: 15000,
105+
},
106+
];
107+
108+
const defaultConfig = {
109+
savings: {
110+
label: 'Savings',
111+
theme: { light: '#42e08c', dark: '#359763' },
112+
},
113+
idealBurndown: {
114+
label: 'Ideal Burndown',
115+
theme: { light: '#3b82f6', dark: '#60a5fa' },
116+
},
117+
remainingSavings: {
118+
label: 'Remaining Savings',
119+
theme: { light: '#eab308', dark: '#facc15' },
120+
},
121+
remainingRecommendations: {
122+
label: 'Remaining Recommendations',
123+
theme: { light: '#ec4899', dark: '#f472b6' },
124+
},
125+
};
126+
127+
/**
128+
* A simple combo chart with one bar series (Savings) and three line series.
129+
*/
130+
export const Default: Story = {
131+
args: {
132+
data: defaultData,
133+
config: defaultConfig,
134+
},
135+
};
136+
137+
/**
138+
* Combo chart with both left (bar) and right (line) Y-axes visible.
139+
* Axis ranges are derived from the data values.
140+
*/
141+
export const WithAxes: Story = {
142+
args: {
143+
data: defaultData,
144+
config: defaultConfig,
145+
showLeftYAxis: true,
146+
showRightYAxis: true,
147+
},
148+
};
149+
150+
/**
151+
* Combo chart with the legend displayed above the chart.
152+
*/
153+
export const WithLegend: Story = {
154+
args: {
155+
data: defaultData,
156+
config: defaultConfig,
157+
showLegend: true,
158+
},
159+
};
160+
161+
/**
162+
* Combo chart combining bars with line series, both axes, legend, and custom bar spacing.
163+
*/
164+
export const AllFeatures: Story = {
165+
args: {
166+
data: defaultData,
167+
config: defaultConfig,
168+
showLeftYAxis: true,
169+
showRightYAxis: true,
170+
showLegend: true,
171+
barCategoryGap: '55%',
172+
legendClassName: 'mb-[26px] justify-end mr-[50px]',
173+
},
174+
};
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import * as React from 'react';
2+
import { Bar, ComposedChart, Customized, Line, XAxis, YAxis } from 'recharts';
3+
import {
4+
ChartConfig,
5+
ChartContainer,
6+
ChartLegend,
7+
ChartLegendContent,
8+
ChartTooltip,
9+
ChartTooltipContent,
10+
PlotAreaBackground,
11+
} from './chart';
12+
import { useHiddenKeys } from './use-hidden-keys';
13+
14+
export type ComboChartBarDefinition = {
15+
dataKey: string;
16+
radius?: number | [number, number, number, number];
17+
stackId?: string;
18+
};
19+
20+
export type ComboChartLineDefinition = {
21+
dataKey: string;
22+
dot?: boolean;
23+
strokeWidth?: number;
24+
type?: 'monotone' | 'natural' | 'linear' | 'step';
25+
};
26+
27+
export type ComboChartProps = {
28+
data: Record<string, unknown>[];
29+
config: ChartConfig;
30+
bars: ComboChartBarDefinition[];
31+
lines: ComboChartLineDefinition[];
32+
leftYAxisTickFormatter?: (value: any) => string;
33+
rightYAxisTickFormatter?: (value: any) => string;
34+
xAxisTickFormatter?: (value: any) => string;
35+
xAxisKey?: string;
36+
showXAxis?: boolean;
37+
showLeftYAxis?: boolean;
38+
showRightYAxis?: boolean;
39+
leftYAxisTicks?: number[];
40+
rightYAxisTicks?: number[];
41+
leftYAxisDomain?: [number | string, number | string];
42+
rightYAxisDomain?: [number | string, number | string];
43+
showTooltip?: boolean;
44+
showLegend?: boolean;
45+
barSize?: number;
46+
barCategoryGap?: number | string;
47+
legendClassName?: string;
48+
className?: string;
49+
};
50+
51+
export function ComboChart({
52+
data,
53+
config,
54+
bars,
55+
lines,
56+
leftYAxisTickFormatter,
57+
rightYAxisTickFormatter,
58+
xAxisTickFormatter,
59+
xAxisKey = 'name',
60+
showXAxis = true,
61+
showLeftYAxis = false,
62+
showRightYAxis = false,
63+
leftYAxisTicks,
64+
rightYAxisTicks,
65+
leftYAxisDomain,
66+
rightYAxisDomain,
67+
showTooltip = true,
68+
showLegend = false,
69+
barSize,
70+
barCategoryGap,
71+
legendClassName,
72+
className,
73+
}: ComboChartProps): React.JSX.Element {
74+
const { hiddenKeys, toggleKey } = useHiddenKeys();
75+
76+
return (
77+
<ChartContainer config={config} className={className}>
78+
<ComposedChart
79+
data={data}
80+
accessibilityLayer
81+
barSize={barSize}
82+
barCategoryGap={barCategoryGap}
83+
>
84+
<Customized component={PlotAreaBackground} />
85+
{showXAxis && (
86+
<XAxis
87+
dataKey={xAxisKey}
88+
tickLine={false}
89+
tickMargin={20}
90+
axisLine={false}
91+
tickFormatter={xAxisTickFormatter}
92+
tick={{
93+
fill: 'hsl(var(--foreground))',
94+
}}
95+
/>
96+
)}
97+
<YAxis
98+
yAxisId="left"
99+
orientation="left"
100+
hide={!showLeftYAxis}
101+
tickFormatter={leftYAxisTickFormatter}
102+
tickLine={false}
103+
axisLine={false}
104+
ticks={leftYAxisTicks}
105+
domain={leftYAxisDomain}
106+
allowDataOverflow
107+
tick={{
108+
fill: 'hsl(var(--foreground))',
109+
}}
110+
/>
111+
<YAxis
112+
yAxisId="right"
113+
orientation="right"
114+
hide={!showRightYAxis}
115+
tickFormatter={rightYAxisTickFormatter}
116+
tickLine={false}
117+
axisLine={false}
118+
ticks={rightYAxisTicks}
119+
domain={rightYAxisDomain}
120+
allowDataOverflow
121+
tick={{
122+
fill: 'hsl(var(--foreground))',
123+
}}
124+
/>
125+
{showTooltip && (
126+
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
127+
)}
128+
{showLegend && (
129+
<ChartLegend
130+
verticalAlign="top"
131+
content={
132+
<ChartLegendContent
133+
className={legendClassName}
134+
onItemClick={toggleKey}
135+
hiddenKeys={hiddenKeys}
136+
/>
137+
}
138+
/>
139+
)}
140+
{bars.map((bar) => (
141+
<Bar
142+
key={bar.dataKey}
143+
yAxisId="left"
144+
dataKey={bar.dataKey}
145+
fill={`var(--color-${bar.dataKey})`}
146+
radius={bar.radius ?? 4}
147+
stackId={bar.stackId}
148+
hide={hiddenKeys.has(bar.dataKey)}
149+
/>
150+
))}
151+
{lines.map((line) => (
152+
<Line
153+
key={line.dataKey}
154+
yAxisId="right"
155+
dataKey={line.dataKey}
156+
stroke={`var(--color-${line.dataKey})`}
157+
dot={line.dot ?? false}
158+
strokeWidth={line.strokeWidth ?? 2}
159+
type={line.type ?? 'monotone'}
160+
hide={hiddenKeys.has(line.dataKey)}
161+
/>
162+
))}
163+
</ComposedChart>
164+
</ChartContainer>
165+
);
166+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './bar-chart';
22
export * from './chart';
3+
export * from './combo-chart';

0 commit comments

Comments
 (0)