본문 바로가기

React

[React] Bar chart 만들기

 

프로젝트 진행하면서, 특히 Analytics 계열 웹사이트의 경우, 차트/그래프는 뗄레야 뗄 수 없다. 회사일 중 대표(친구라 반말체로 쓰겠습니다)가 반드시! 꼭! 저 디자인 그대로 만들어달라 하길래, 걸맞는 모듈 검색하다가 문득, 차라리 만드는것이 더 빠르겠다 싶어 만들어 보았습니다.

 

Layout

글씨체는 애교로 봐주세용 희희 >.<

1. 차트가 여러 곳에서 다양한 크기로 쓰일 수 있음

2. 치역의 단위는 %만 사용함

3. 정의역은 개수가 정해지지 않음

4. 반응형

이정도가 조건으로 주어져서, 그에 맞게 구상을 해보았습니다.

 

위에 못난 그림을 정리하면 아래와 같습니다.

1. Container로 전체적인 레이아웃 조정

2. Container하위 요소들은 모두 Container 기반 상대 크기

3. 정의역이 추가될 때마다, Container내에 Column 컨포넌트를 추가

4. Row의 개수는, yLabel의 개수

 

파일 구조

 

Chart.tsx

이 컨포넌트에서 하는일은 크게 3가지인데.

1. yLabel 계산 (generateYLabelValue)

  1. dataMax (data중 가장 큰 값)찾기
  2. dataMax 동자릿수 중 dataMax보다 크면서, 최고 자릿수 이하는 0인수 찾기

2. X-Label , Y-Label 렌더 - 사실 X-lable은 Column.tsx로 보내서, 렌더하는게 훨씬 편하고 낫다고 생각하는데, 뭔가 이 컨포넌트에서 라벨들은 처리하고 가는게 깔끔해보여서 이렇게 했습니다.

 

3. 실제 차트 렌더

 

//Chart.tsx

// !!! 중요 , 컨포넌트 반환부에, <Heading> <- <h1>으로 생각하시면됩니다

// styled-component props
interface XLabelProps {
	xLabelSpacing?: string;
	left: number;
}
interface YLabelProps {
	yLabelSpacing?: string;
	top: number;
}



// data 중 제일 큰 값을 구합니다, BarChartDataType의 멤버 value는 number | number[]이기에
// 경우를 나눠서 구합니다
const getMaxData = (data : BarChartDataType[]) => {
	let dataMax = 0;
	if (data[0].value instanceof Array) {
		const tmp = [];

		for (const d of data) {
			const t = d.value as Array<number>;
			tmp.push(...t);
		}
		dataMax = Math.ceil(Math.max(...tmp));
	} else {
		const valueList : number[] = [] 
		for(const d of data){
			valueList.push(d.value as number)
		}
		dataMax = Math.ceil(Math.max(...valueList));
	}
	return dataMax
}

//yLabel 배열을 반환합니다.
const generateYlabelValue = (dataMax : number , rowCount : number) => {
	let yLab = null;
	let digit = 1
	let highestMaxDigit = null;
	while(dataMax % digit !== dataMax){
		digit *=10;
	}
	highestMaxDigit = digit / 10;
	while(highestMaxDigit !== digit){
		if(Math.floor(dataMax / highestMaxDigit) === 0){
			break;
		}
		highestMaxDigit = highestMaxDigit + (digit / 10)
	}
	dataMax = highestMaxDigit;		
		
	const amountPerBox = Number(highestMaxDigit / rowCount);
	yLab = [highestMaxDigit];
    
	for (let i = 0; i < rowCount - 1; i++) {
		yLab.push(yLab[yLab.length - 1] - amountPerBox);
	}
	yLab.push(0);
	return yLab;
}
/**
 *
 * @param {BarChartDataType[]} data
 * @param {number} rowCount - number of rows , (default = 5)
 * @param {CSSObject} [labelStyle] - style objects apply to labels
 * @param {string} [fontSize] - size of font
 * @param {number} [rowCount] - (!important) if type of yLabel is string, must set value of this
 * @param {string} [yLabelUnit] - unit of y-label
 *
 */
const Chart = ({
	data,
	labelStyle,
	yLabelSpacing,
	xLabelSpacing,
	fontSize,
	rowCount = 5,
	grouped,
	yLabelUnit = '%',
	color,
}: ChartProps) => {
	const dataMax = getMaxData(data)
	const yLab = generateYlabelValue(dataMax , rowCount);

	return (
		<Container>
			{data.map((item, idx) => {
				if (grouped) {
					return (
						<Column
							key={'barchart-column' + idx + item}
							rows={rowCount}
							percent={item.value}
							grouped={grouped}
							color={color}
							dataMax={dataMax}
						/>
					);
				} else {
					return (
						<Column
							key={'barchart-column' + idx + item}
							rows={rowCount}
							percent={((item.value as number) / dataMax) * 100}
							grouped={grouped}
							color={color}
							dataMax={dataMax}
						/>
					);
				}
			})}

			{yLab?.map((item: number, idx: number) => {
				return (
				<Ylabel key={'barchart-y-axis ' + item} top={idx * (1 / rowCount) * 100} yLabelSpacing={yLabelSpacing}>
					<Heading variant="H9" style={{ ...labelStyle, fontSize: fontSize }}>
						{item}
						{yLabelUnit}
					</Heading>
				</Ylabel>
			)})}
			{
				data.map(({label}, idx) => {
						return (
							<Xlabel
								key={'barchart-x-axis ' + label + idx}
								left={idx * (1 / data.length) * 100 + (0.5 / data.length) * 100}
								xLabelSpacing={xLabelSpacing}
							>
								<Heading variant="H9" style={{ ...labelStyle, fontSize: fontSize }}>
									{label}
								</Heading>
							</Xlabel>
						);
				})
			}
		</Container>
	);
};

export default Chart;

const Container = styled.div`
	border: 1px solid var(--color-white-300);
	border-right: none;
	display: flex;
	width: 100%;
	height: 100%;
	position: relative;
	margin-bottom: 30px;
`;
const Ylabel = styled.span<YLabelProps>`
	position: absolute;
	right: ${({ yLabelSpacing }) => (yLabelSpacing ? `calc(100% + ${yLabelSpacing})` : `calc(100% + 1rem)`)};
	${({ top }) => ({ top: top < 100 ? `calc(${top}% - 6px)` : `calc(100% - 10px)` })};
`;
const Xlabel = styled.div<XLabelProps>`
	position: absolute;
	top: calc(100% + 6px);
	width: 30px;
	text-align: center;
	left: ${({ xLabelSpacing, left }) =>
		xLabelSpacing ? `calc(${left}% - ${xLabelSpacing})` : `calc(${left}% - 15px)`};
`;

 

Column.tsx

rowCount에 맞춰서, Row 컨포넌트를  쌓고, Percentage 컨포넌트를 y축 방향으로 percent(prop)만큼 scale하면 끝!

 

interface PercentageProps {
	[index: number]: string;
	percent: number;
	width?: string;
	grouped: boolean;
}
interface RowProps {
	slice: number;
	first: boolean;
}
/**
 *
 * @param {number} rows - row count
 * @param {boolean} grouped - set true to use grouped bar-chart (default : false)
 * @param {number | number[]} percent - data ratio of current data from data set (!important, when using grouped bar chart, should give 2-length Array)
 * @param {string | [string , string]} color - color of bar, (!important, when using grouped bar chart, should give 2-length Array) (default : 'tomato')
 * @param {string} [barWidth] - set static value of bar width (bar width defaults as responsive)
 * @returns React.Component
 */
const Column = ({ rows, grouped = false, barWidth, percent = 0, color = 'tomato', dataMax }: BarChartColumnProps) => {
	const [percentage, setPercentage] = useState<number | [number, number]>(0);

	const iterator = new Array(rows).fill(Math.random());
	useEffect(() => {
		
        // props 타입 검사
		if (grouped) {
			if (!(percent instanceof Array) || !(color instanceof Array)) {
				console.log(!(percent instanceof Array) ? 'percent' : 'color' + 'wrong arg format');
				return;
			}
			setPercentage([(percent[0] / dataMax) * 100, (percent[1] / dataMax) * 100]);
		} else {
			if (percent instanceof Array || color instanceof Array) {
				console.log(!(percent instanceof Array) ? 'percent' : 'color' +'wrong arg format');
				return;
			}
			setPercentage(percent);
		}
	}, []);


	return (
		<Container>
			{iterator.map((item, idx) => {
				return <Row key={'BarChartColumn-' + item + idx} slice={rows} first={idx === 0} />;
			})}
			<PercentGroup>
				<Percentage
					percent={
						!grouped && typeof percentage === 'number'
							? percentage
							: percentage instanceof Array
							? percentage[0]
							: 0
					}
					width={barWidth}
					grouped={grouped}
					color={!grouped ? (color as string) : color[0]}
				/>
				{grouped && percentage instanceof Array ? (
					<Percentage percent={percentage[1]} width={barWidth} grouped={grouped} color={color[1]} />
				) : null}
			</PercentGroup>
		</Container>
	);
};

export default Column;

const Container = styled.div`
	position: relative;
	width: 100%;
	height: 100%;
	border-right: 1px solid var(--color-white-300);
	display: flex;
	flex-direction: column;
	align-items: center;
`;

const Row = styled.div<RowProps>`
	width: 100%;
	height: calc(100% / ${({ slice }) => slice});
	border-top: 1px solid var(--color-white-300);
	${({ first }) =>
		first
			? {
					border: 'none'
			  }
			: null}
`;

const PercentGroup = styled.div`
	display: flex;
	position: absolute;
	bottom: 0;
	width: 100%;
	height: 100%;
	justify-content: center;
	align-items: flex-end;
`;
const Percentage = styled.div<PercentageProps>`
	transform-origin: bottom center;
	transition: transform 1s ease-in-out;
	${({ percent }) => ({ transform: `scaleY(${percent || 0}%)` })};
	${({ width, grouped }) => (grouped ? { width: `${width || '35%'}` } : { width: width || '50%' })};
	height: 100%;
	border-top-left-radius: 2px;
	border-top-right-radius: 2px;
	background-color: ${({ color }) => color || 'yellow'};
`;

 

 

Type Declaration

export interface BarChartColumnProps {
	rows: number;
	grouped: boolean;
	barWidth?: string;
	percent: percentType;
	color: string | string[];
	dataMax: number;
}

export type percentType = number | number[];

export interface BarChartDataType{
	label : string | string[],
	value : number | number[]
}

export interface ChartProps {
	data: BarChartDataType[];
	rowCount?: number;
	labelStyle?: CSSObject;
	fontSize?: string;
	grouped: boolean;
	color: string | string[];
	xLabelSpacing?: string;
	yLabelSpacing?: string;
	yLabelUnit?: string;
}