Skip to content

Commit bb1582d

Browse files
authored
Created Waterfall Chart component
2 parents afbd05b + a6908d2 commit bb1582d

File tree

10 files changed

+484
-2
lines changed

10 files changed

+484
-2
lines changed

src/constants.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export const DEFAULT_BAR_WIDTH = 50;
2+
3+
export const DEFAULT_PIXELS_PER_Y_UNIT = 30;
4+
5+
export const DEFAULT_SUMMARY_LABEL = 'Total';
6+
7+
export const FINAL_SUMMARY_GRAPH_KEY = 'final-summary-bar';
8+
9+
export const FINAL_SUMMARY_X_LABEL_KEY = 'final-summary-x-point';

src/index.css

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
html,
2-
body {
2+
body,
3+
#root {
34
margin: 0;
45
padding: 0;
56
font-family: sans-serif;
67
height: 100%;
8+
width: 100%;
79
}
810

911
h1 {

src/stories/Component.stories.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import React from 'react';
2+
import { ComponentStory, ComponentMeta } from '@storybook/react';
3+
import Component from '../waterfall-chart';
4+
5+
export default {
6+
title: 'Example/WaterFallChart',
7+
component: Component,
8+
parameters: {
9+
// More on Story layout: https://storybook.js.org/docs/react/configure/story-layout
10+
layout: 'fullscreen'
11+
}
12+
} as ComponentMeta<typeof Component>;
13+
14+
const Template: ComponentStory<typeof Component> = (args) => (
15+
<div style={{ width: '100%', height: '100%', padding: '100px 200px' }}>
16+
<Component {...args} />
17+
</div>
18+
);
19+
export const WaterFallChart = Template.bind({});
20+
WaterFallChart.args = {
21+
transactions: [
22+
{
23+
label: 'Income',
24+
value: 10
25+
},
26+
{
27+
label: 'Expense1',
28+
value: 3
29+
},
30+
{
31+
label: 'Gain1',
32+
value: -2
33+
},
34+
{
35+
label: 'Expense2',
36+
value: -60
37+
},
38+
{
39+
label: 'Gain2',
40+
value: 40
41+
},
42+
{
43+
label: 'Expense3',
44+
value: -10
45+
},
46+
{
47+
label: 'Gain3',
48+
value: 80
49+
}
50+
]
51+
};

src/types/types.d.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { CSSProperties } from 'react';
2+
3+
export type IWaterfallGraphProps = {
4+
transactions: Array<ITransaction>;
5+
barWidth?: number;
6+
showBridgeLines?: boolean;
7+
showYAxisScaleLines?: boolean;
8+
yAxisPixelsPerUnit?: number;
9+
showFinalSummary?: boolean;
10+
summaryXLabel?: string;
11+
summaryBarStyles?: CSSProperties;
12+
positiveBarStyles?: CSSProperties;
13+
negativeBarStyles?: CSSProperties;
14+
onChartClick?: IOnChartClick;
15+
};
16+
17+
export type ITransaction = {
18+
label: string;
19+
value: number;
20+
color?: string;
21+
};
22+
23+
export type IChartElement = {
24+
name: string;
25+
value: number;
26+
yVal: number;
27+
cumulativeSum: number;
28+
barHeight: number;
29+
};
30+
31+
export type IUseWaterfallChartReturnType = {
32+
chartElements: Array<IChartElement>;
33+
yValueForZeroLine: number;
34+
yAxisPoints: Array<number>;
35+
yAxisScale: number;
36+
calculateBarWidth: ICalculateBarWidth;
37+
};
38+
39+
export type IGetIntervalAndYPointsReturnType = {
40+
yAxisScale: number;
41+
yAxisPoints: Array<number>;
42+
};
43+
44+
export type ICalculateBarWidth = (graphWidth: number) => number;
45+
46+
export type IOnChartClick = (chartElement: IChartElement) => void;
47+
48+
export enum chartTypes {
49+
transaction,
50+
summary
51+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import React, { FC, useEffect, useRef, useState } from 'react';
2+
import { IWaterfallGraphProps } from '../types/types';
3+
import useWaterfallChart from './useWaterFallChart';
4+
import styles from './styles.module.scss';
5+
6+
import {
7+
DEFAULT_BAR_WIDTH,
8+
DEFAULT_PIXELS_PER_Y_UNIT,
9+
DEFAULT_SUMMARY_LABEL,
10+
FINAL_SUMMARY_GRAPH_KEY,
11+
FINAL_SUMMARY_X_LABEL_KEY
12+
} from '../constants';
13+
14+
const WaterFallChart: FC<IWaterfallGraphProps> = (props) => {
15+
const {
16+
transactions,
17+
barWidth,
18+
showBridgeLines = true,
19+
showYAxisScaleLines = false,
20+
yAxisPixelsPerUnit = DEFAULT_PIXELS_PER_Y_UNIT,
21+
showFinalSummary = true,
22+
summaryXLabel = DEFAULT_SUMMARY_LABEL,
23+
summaryBarStyles = {},
24+
positiveBarStyles = {},
25+
negativeBarStyles = {},
26+
onChartClick
27+
} = props;
28+
29+
const wrapperRef = useRef<HTMLDivElement | null>(null);
30+
const [wrapperHeight, setWrapperHeight] = useState(0);
31+
const [barWidthVal, setBarWidthVal] = useState(barWidth ?? DEFAULT_BAR_WIDTH);
32+
33+
const { chartElements, yValueForZeroLine, yAxisPoints, yAxisScale, calculateBarWidth } = useWaterfallChart(
34+
transactions,
35+
wrapperHeight,
36+
yAxisPixelsPerUnit,
37+
showFinalSummary
38+
);
39+
40+
useEffect(() => {
41+
const onWrapperDimensionsChange = (): void => {
42+
if (wrapperRef.current) {
43+
setWrapperHeight(wrapperRef?.current?.offsetHeight);
44+
if (!barWidth || barWidth <= 0) setBarWidthVal(calculateBarWidth(wrapperRef?.current?.offsetWidth));
45+
}
46+
};
47+
48+
onWrapperDimensionsChange();
49+
50+
window.addEventListener('resize', onWrapperDimensionsChange);
51+
52+
return () => window.removeEventListener('resize', onWrapperDimensionsChange);
53+
}, [barWidth, calculateBarWidth]);
54+
55+
const summaryValue = Math.abs(chartElements[chartElements?.length - 1]?.cumulativeSum);
56+
const summaryBarHeight = Math.abs((summaryValue / yAxisScale) * yAxisPixelsPerUnit);
57+
const summaryChartElement = {
58+
name: summaryXLabel,
59+
value: summaryValue,
60+
yVal: yValueForZeroLine - (summaryValue / yAxisScale) * yAxisPixelsPerUnit,
61+
cumulativeSum: 0,
62+
barHeight: summaryBarHeight
63+
};
64+
65+
return (
66+
<div ref={wrapperRef} className={styles.chartWrapper}>
67+
<svg className={styles.svgContainer}>
68+
{/* y-axis */}
69+
<line x1='0' y1='0' x2='0' y2='100%' className={styles.axisLines} />
70+
{/* x-axis */}
71+
<line x1='0' y1='100%' x2='100%' y2='100%' className={styles.axisLines} />
72+
{/*y axis scale lines */}
73+
{showYAxisScaleLines && yAxisPoints?.map((yPoint, index) => (
74+
<line
75+
key={yPoint}
76+
x1='0'
77+
y1={wrapperHeight - index * yAxisPixelsPerUnit}
78+
x2='100%'
79+
y2={wrapperHeight - index * yAxisPixelsPerUnit}
80+
className={`${styles.axisLines}`}
81+
/>
82+
))}
83+
{chartElements?.map((chartElement, index) => (
84+
<>
85+
<rect
86+
key={`${chartElement?.name}-bar-graph`}
87+
width={barWidthVal}
88+
height={chartElement?.barHeight}
89+
y={chartElement?.yVal}
90+
x={(2 * index + 1) * barWidthVal}
91+
className={`${styles.graphBar} ${chartElement?.value >= 0 ? styles.positiveGraph : styles.negativeGraph}`}
92+
style={chartElement?.value >= 0 ? positiveBarStyles : negativeBarStyles}
93+
onClick={(): void => onChartClick && onChartClick(chartElement)}
94+
/>
95+
{showBridgeLines && (showFinalSummary || index !== chartElements?.length - 1) && (
96+
<line
97+
key={`${chartElement?.name}-bridge-line`}
98+
className={styles.bridgeLine}
99+
x1={(2 * index + 2) * barWidthVal}
100+
y1={yValueForZeroLine - (chartElement?.cumulativeSum / yAxisScale) * yAxisPixelsPerUnit}
101+
x2={(2 * index + 3) * barWidthVal}
102+
y2={yValueForZeroLine - (chartElement?.cumulativeSum / yAxisScale) * yAxisPixelsPerUnit}
103+
/>
104+
)}
105+
</>
106+
))}
107+
{showFinalSummary && (
108+
<rect
109+
key={FINAL_SUMMARY_GRAPH_KEY}
110+
width={barWidthVal}
111+
height={summaryChartElement?.barHeight}
112+
y={summaryChartElement?.yVal}
113+
x={(2 * chartElements?.length + 1) * barWidthVal}
114+
className={`${styles.graphBar} ${styles.summaryGraphBar}`}
115+
onClick={(): void => onChartClick && onChartClick(summaryChartElement)}
116+
/>
117+
)}
118+
</svg>
119+
<div className={styles.yPoints}>
120+
{yAxisPoints?.map((yAxisPoint, index) => (
121+
<div
122+
key={yAxisPoint}
123+
className={styles.yPoint}
124+
style={{ bottom: index * yAxisPixelsPerUnit - 8 }}
125+
>
126+
{yAxisPoint}
127+
</div>
128+
))}
129+
</div>
130+
<div className={styles.xPoints}>
131+
{transactions?.map((transaction, index) => (
132+
<div
133+
key={transaction?.label}
134+
className={styles.xPoint}
135+
style={{ left: (2 * index + 1.25) * barWidthVal }}
136+
// the 1.25 is to reduce chances for the label to overflow to right
137+
>
138+
{transaction?.label}
139+
</div>
140+
))}
141+
{showFinalSummary && (
142+
<div
143+
key={FINAL_SUMMARY_X_LABEL_KEY}
144+
className={styles.xPoint}
145+
style={{ ...summaryBarStyles, left: (2 * chartElements?.length + 1.25) * barWidthVal }}
146+
>
147+
{summaryXLabel}
148+
</div>
149+
)}
150+
</div>
151+
</div>
152+
);
153+
};
154+
155+
export default WaterFallChart;

src/waterfall-chart/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import WaterFallChart from './WaterFallChart';
2+
3+
export default WaterFallChart;
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
#root {
2+
width: 100%;
3+
height: 100%;
4+
}
5+
* {
6+
margin: 0;
7+
padding: 0;
8+
box-sizing: border-box;
9+
}
10+
.chartWrapper {
11+
height: calc(100% - 40px);
12+
width: calc(100% - 40px);
13+
margin: 20px;
14+
}
15+
16+
.svgContainer {
17+
height: 100%;
18+
width: 100%;
19+
display: block;
20+
21+
.axisLines {
22+
stroke-dasharray: 1;
23+
stroke: rgb(158, 158, 158);
24+
stroke-width: 2;
25+
transition: all 0.3s ease-in-out;
26+
}
27+
.hideLine {
28+
opacity: 0;
29+
}
30+
.graphBar {
31+
stroke-width: 2;
32+
z-index: 1;
33+
transition: all 0.3s ease-in-out;
34+
}
35+
.summaryGraphBar {
36+
fill: #076aff;
37+
stroke: #076aff;
38+
}
39+
.positiveGraph {
40+
fill: #00b050;
41+
stroke: #00b050;
42+
}
43+
.negativeGraph {
44+
fill: #ff0000;
45+
stroke: #ff0000;
46+
}
47+
.bridgeLine {
48+
stroke: #545453;
49+
stroke-width: 2;
50+
z-index: 1;
51+
}
52+
}
53+
.yPoints {
54+
position: relative;
55+
width: 0px;
56+
.yPoint {
57+
position: absolute;
58+
font-size: 11px;
59+
line-height: 14px;
60+
color: #545453;
61+
right: 0px;
62+
text-align: right;
63+
}
64+
}
65+
66+
.xPoints {
67+
position: relative;
68+
height: 0px;
69+
.xPoint {
70+
position: absolute;
71+
font-size: 11px;
72+
line-height: 14px;
73+
color: #545453;
74+
top: 5px;
75+
text-align: center;
76+
}
77+
}

0 commit comments

Comments
 (0)