개발/React & React Native

[React] 리액트 라인차트 (Line Chart) 컴포넌트 만들기

뚀니 Ddoeni 2024. 11. 8. 18:28
728x90



지난 글에 이어 이번에는 SVG를 활용하여 라인 차트를 그리고 react-spring으로 애니메이션 효과를 추가해 주었다.

차트의 데이터 배열, 최대 데이터 수, 차트의 Y축의 최댓값을 받아 변형되는 라인차트 컴포넌트를 만들었다.

 

RN으로도 해당 차트를 그리기도 했었는데, RN의 경우에는 이 코드를 많이 참고했었다.

https://github.com/indiespirit/react-native-chart-kit/tree/master

 

LineChart.tsx

import { useCallback, useEffect, useRef, useState } from "react";
import { useSpring, animated } from "react-spring";

const DEFAULT_FILL_COLOR = "rgba(104, 204, 202, 0.3)";
const DEFAULT_STROKE_COLOR = "#16A5A5";
const DEFAULT_MAX_VALUE_COLOR = "#ff0000";

interface LineChartState {
  minValue: number;
  maxValue: number;
  stepX: number;
  stepY: number;
  horizontalLines: number[];
  chartValueArray: number[];
}

interface LineChartProps {
  valueArray: number[];
  maxXAxisValue: number;
  maxYAxisValue: number;
  width?: number;
  height?: number;
  paddingTop?: number;
  paddingLeft?: number;
  paddingRight?: number;
}

const LineChart = (props: LineChartProps) => {
  const {
    valueArray,
    maxXAxisValue,
    maxYAxisValue,
    width = 500,
    height = 300,
    paddingTop = 20,
    paddingLeft = 20,
    paddingRight = 20,
  } = props;

  const [state, setState] = useState<LineChartState>({
    minValue: 0,
    maxValue: 100,
    stepX: 0,
    stepY: 0,
    horizontalLines: [],
    chartValueArray: [],
  });

  const pathRef = useRef<SVGPathElement | null>(null);
  const [pathLength, setPathLength] = useState<number>(0);
  const { length } = useSpring({
    from: { length: 0 },
    to: { length: 1 },
    reset: true,
    config: { duration: 800 },
  });

  useEffect(() => {
    if (pathRef.current) {
      const totalLength = pathRef.current.getTotalLength();
      setPathLength(totalLength);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [state.chartValueArray]);

  const makeLineChart = useCallback(() => {
    const horizontalLines: number[] = [];
    const stepX = (width - (paddingLeft + paddingRight)) / maxXAxisValue;
    const stepY = (height - paddingTop) / (state.maxValue - state.minValue);

    const values = valueArray.map((item) =>
      item > 0 ? (item * 100) / maxYAxisValue : 0
    );

    const axisHStep = state.maxValue / 6;
    for (let i = 0; i <= 6; i++) {
      horizontalLines.push(axisHStep * i);
    }

    setState((prev) => ({
      ...prev,
      stepX,
      stepY,
      horizontalLines,
      chartValueArray: values,
    }));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [valueArray, maxXAxisValue, maxYAxisValue, width, height]);

  useEffect(() => {
    makeLineChart();
  }, [makeLineChart]);

  useEffect(() => {
    setPathLength(0);
    if (pathRef.current) {
      const totalLength = pathRef.current.getTotalLength();
      setPathLength(totalLength);
    }
  }, [valueArray, maxXAxisValue, maxYAxisValue]);

  const getPosition = (itemval: number, itemindex: number) => {
    const x = itemindex * state.stepX + paddingLeft;
    const y = -((itemval - state.minValue) * state.stepY);
    return { x, y };
  };

  const getChartPath = () => {
    return state.chartValueArray.reduce((path, number, index) => {
      const { x, y } = getPosition(Math.min(number, state.maxValue), index);
      return path + (index === 0 ? `M${paddingLeft},${y}` : ` L${x},${y}`);
    }, "");
  };

  const getChartPolygon = () => {
    const start = -(state.minValue * -1 * state.stepY);
    let path = `${paddingLeft},${start}`;

    state.chartValueArray.forEach((number: number, index: number) => {
      const { x, y } = getPosition(Math.min(number, state.maxValue), index);
      path += index === 0 ? ` ${paddingLeft},${y}` : ` ${x},${y}`;

      if (index === state.chartValueArray.length - 1) {
        path += ` ${
          (state.chartValueArray.length - 1) * state.stepX + paddingLeft + 2
        },${y}`;
      }
    });

    path += ` ${
      (state.chartValueArray.length - 1) * state.stepX + paddingLeft + 2
    },${start}`;
    path += ` ${paddingLeft},${start}`;

    return path;
  };

  const renderChartData = () => (
    <g>
      <animated.path
        ref={pathRef}
        d={getChartPath()}
        fill="none"
        stroke={DEFAULT_STROKE_COLOR}
        strokeWidth={2}
        strokeDasharray={pathLength}
        strokeDashoffset={length.to([0, 1], [pathLength, 0])}
      />
      <animated.polygon
        points={getChartPolygon()}
        fill={DEFAULT_FILL_COLOR}
        style={{
          opacity: length,
        }}
      />
    </g>
  );

  const renderChartPoint = () => (
    <>
      {state.chartValueArray.map((item, index) => {
        if (item === -1) return null;
        const position = getPosition(
          item > state.maxValue ? state.maxValue : item,
          index
        );
        return (
          <circle
            key={index}
            cx={position.x}
            cy={position.y}
            r={6}
            fill={
              item >= state.maxValue
                ? DEFAULT_MAX_VALUE_COLOR
                : DEFAULT_STROKE_COLOR
            }
          />
        );
      })}
    </>
  );

  const renderHorizontalLine = () => (
    <>
      {state.horizontalLines.map((item, index) => (
        <line
          key={index}
          x1={0}
          y1={-((item - state.minValue) * state.stepY)}
          x2={maxXAxisValue * state.stepX}
          y2={-((item - state.minValue) * state.stepY)}
          stroke={"#f2f2f2"}
          strokeWidth={1}
        />
      ))}
    </>
  );

  const renderVerticalLine = () => {
    const y = -((state.maxValue - state.minValue) * state.stepY);
    return (
      <>
        {[...Array(maxXAxisValue)].map((_, index) => (
          <line
            key={index}
            x1={index * state.stepX + paddingLeft}
            y1={0}
            x2={index * state.stepX + paddingLeft}
            y2={y}
            stroke={"#f2f2f2"}
            strokeWidth={1}
          />
        ))}
      </>
    );
  };

  return (
    <div>
      <svg width={width} height={height}>
        <g transform={`translate(0, ${height})`}>
          {renderHorizontalLine()}
          {renderVerticalLine()}
          {renderChartData()}
          {renderChartPoint()}
        </g>
      </svg>
    </div>
  );
};

export default LineChart;

 

 




interface LineChartProps {
  valueArray: number[];
  maxXAxisValue: number;
  maxYAxisValue: number;
  width?: number;
  height?: number;
  paddingTop?: number;
  paddingLeft?: number;
  paddingRight?: number;
}

 

작성된 코드에는 valueArray, maxXAxis, maxYAxis 값만 필수로 적용하고 나머지 항목들은 optional 처리해 둔 상태이다.

상황에 따라서 width, height, padding 값을 변경할 수 있다.



 

const makeLineChart = useCallback(() => {
  const horizontalLines: number[] = [];
  const stepX = (width - (paddingLeft + paddingRight)) / maxXAxisValue;
  const stepY = (height - paddingTop) / (state.maxValue - state.minValue);

  const values = valueArray.map((item) =>
    item > 0 ? (item * 100) / maxYAxisValue : 0
  );

  const axisHStep = state.maxValue / 6;
  for (let i = 0; i <= 6; i++) {
    horizontalLines.push(axisHStep * i);
  }

  setState((prev) => ({
    ...prev,
    stepX,
    stepY,
    horizontalLines,
    chartValueArray: values,
  }));
}, [valueArray, maxXAxisValue, maxYAxisValue, width, height]);


makeLineChart는 X, Y축 비율에 따라 각 데이터 포인트를 위치시킬 수 있도록 차트 그리기에 필요한 값들을 계산하는 부분이다. 현재는 백분위 비율로 계산해 차트를 그리도록 해두었는데, 최솟값, 최댓값, Y축 증가값을 별도로 설정해 차트를 그려도 무방할 것 같다.

 

 

  const pathRef = useRef<SVGPathElement | null>(null);
  const [pathLength, setPathLength] = useState<number>(0);
  const { length } = useSpring({
    from: { length: 0 },
    to: { length: 1 },
    reset: true,
    config: { duration: 800 },
  });
  
  useEffect(() => {
    if (pathRef.current) {
      const totalLength = pathRef.current.getTotalLength();
      setPathLength(totalLength);
    }
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [state.chartValueArray]);

/* 생략 */


  useEffect(() => {
    setPathLength(0);
    if (pathRef.current) {
      const totalLength = pathRef.current.getTotalLength();
      setPathLength(totalLength);
    }
  }, [valueArray, maxXAxisValue, maxYAxisValue]);

 

차트가 그려질 때 애니메이션을 주기 위한 코드이다. useRef를 이용해 <path> 요소에 접근하고, 이 경로의 총길이를 측정해 pathLength 상태에 저장한다.

 

  • pathRef: <path> 요소에 접근하여 실제 경로 길이를 측정하기 위한 참조
  • pathLength: 경로의 전체 길이를 저장하는 상태로, 애니메이션에 필요한 strokeDasharray와 strokeDashoffset 설정에 사용

useSpring을 통해 length 값을 0에서 1로 변환하는 애니메이션을 설정했다. length가 0에서 1로 증가하면서 차트 경로가 좌측에서 우측으로 부드럽게 그려지는 효과를 줄 수 있다.

 

  • from과 to: 애니메이션 시작과 끝 값. length가 0에서 1로 변화하면서 경로가 서서히 나타나게 된다.
  • reset: 매번 새 데이터를 받을 때(array 값 등의 변화) 애니메이션을 다시 실행하도록 한다.
  • duration: 애니메이션 속도

데이터(valueArray, maxXAxisValue, maxYAxisValue)가 변경될 때마다 경로 길이를 초기화하여 애니메이션이 제대로 실행되도록 하였다.

 

 

 

 

 

  const getPosition = (itemval: number, itemindex: number) => {
    const x = itemindex * state.stepX + paddingLeft;
    const y = -((itemval - state.minValue) * state.stepY);
    return { x, y };
  };

 

각 데이터의 x, y 값을 출력하는 부분이다. 이때, padding 값을 고려해 주기 위해 계산식에 반영해 주었다.

 



const getChartPath = () => {
  return state.chartValueArray.reduce((path, number, index) => {
    const { x, y } = getPosition(Math.min(number, state.maxValue), index);
    return path + (index === 0 ? `M${paddingLeft},${y}` : ` L${x},${y}`);
  }, "");
};


const getChartPolygon = () => {
  const start = -(state.minValue * -1 * state.stepY);
  let path = `${paddingLeft},${start}`;

  state.chartValueArray.forEach((number: number, index: number) => {
    const { x, y } = getPosition(Math.min(number, state.maxValue), index);
    path += index === 0 ? ` ${paddingLeft},${y}` : ` ${x},${y}`;

    if (index === state.chartValueArray.length - 1) {
      path += ` ${
        (state.chartValueArray.length - 1) * state.stepX + paddingLeft + 2
      },${y}`;
    }
  });

  path += ` ${
    (state.chartValueArray.length - 1) * state.stepX + paddingLeft + 2
  },${start}`;
  path += ` ${paddingLeft},${start}`;

  return path;
};

 

라인 차트 경로를 생성하고, 라인 아래 영역을 채우기 위해 path를 생성하는 함수이다.

 

getChartPath 함수는 state.chartValueArray의 각 데이터를 순회하면서 좌표를 계산해 차트의 경로를 생성한다. 경로는 M으로 시작해 각 데이터 포인트를 L로 연결하여 선을 그린다.

 

  • M과 L: M은 시작점, L은 이후의 각 데이터 포인트로 경로를 연결해 준다. 첫 번째 데이터 포인트는 M으로 이동하고, 이후 포인트들은 L로 선을 연결한다.

getChartPolygon 함수는 차트 경로 아래의 영역을 채우기 위한 다각형 경로를 생성한다. 데이터 값에 따라 점을 연결한 후, 차트의 시작점으로 경로를 닫아 <path> 하단의 영역을 채울 수 있도록 한다.

 



 

이외의 함수들은 간단하게 설명한다.

  • renderChartPoint: 각 데이터 포인트 위치에 원형 요소를 생성해 차트 위에 표시한다. 이때 값이 maxYAxis를 초과하면 붉은색으로 표시된다.
  • renderHorizontalLine: Y축 비율에 맞춰 차트의 수평 가이드라인을 렌더링 한다.
  • renderVerticalLine: X축 비율에 맞춰 차트의 수직 가이드라인을 렌더링 한다.

 

 

전체 코드는 마찬가지로 여기서 확인 가능하다!

 

GitHub - hyuni106/chart-example

Contribute to hyuni106/chart-example development by creating an account on GitHub.

github.com

 

 

원형 차트 만들기는 이전글 참고

 

[React] 리액트 원형 프로그래스 (Circle Progress Bar) 컴포넌트 만들기/예제

이런 식으로 생긴 원형 Progress는 자주 사용되는 디자인 요소 중 하나이다. 이런 프로그래스 컴포넌트를 라이브러리가 아닌, 직접 커스텀할 수 있는 컴포넌트로 생성해 보았다. 게시글에 올라가

ramveloper.tistory.com

 

300x250