Act99 기술블로그

[React] 직접 주가 캔들 차트 & 거래량 바 차트 만들기 (라이브러리 x) (SVG 연습용) 본문

개발팁저장소/react

[React] 직접 주가 캔들 차트 & 거래량 바 차트 만들기 (라이브러리 x) (SVG 연습용)

Act99 2021. 11. 27. 18:50

React-Google-Chart 를 사용하던 중, 직접 차트를 만들고 싶은 충동이 생겼다.

먼저 handmade-chart.tsx 를 만들어주고 svg 를 이용해 차트 틀부터 만들어 주었다.

(CSS 경우 tailwind를 사용하였다.)

 

- handmade-chart.tsx

import React from "react";

const SVG_CHART_WIDTH = 1600;
const SVG_CHART_HEIGHT = 400;
const SVG_VOLUME_WIDTH = 1600;
const SVG_VOLUME_HEIGHT = 400;

export const HandmadeChart = () => {
  const x0 = 50;
  const xAxisLength = SVG_CHART_WIDTH - x0 * 2;
  const y0 = 50;
  const yAxisLength = SVG_CHART_HEIGHT - y0 * 2;

  const xAxisY = y0 + yAxisLength;

  return (
    <div className=" bg-chartGray-default flex-col flex">
      <CandleChart />
      <VolumeChart />
    </div>
  );
};

const CandleChart = () => {
  const x0 = 50;
  const xAxisLength = SVG_CHART_WIDTH - x0 * 2;
  const y0 = 50;
  const yAxisLength = SVG_CHART_HEIGHT - y0 * 2;

  const xAxisY = y0 + yAxisLength;

  return (
    <div>
      <svg width={SVG_CHART_WIDTH} height={SVG_CHART_HEIGHT}>
        <line x1={x0} y1={xAxisY} x2={x0 + xAxisLength} y2={xAxisY} />
        <text x={x0 + xAxisLength + 5} y={xAxisY + 10}>
          x
        </text>
        <line x1={x0} y1={y0} x2={x0} y2={y0 + yAxisLength} />
      </svg>
    </div>
  );
};

const VolumeChart = () => {
  const x0 = 50;
  const xAxisLength = SVG_VOLUME_WIDTH - x0 * 2;
  const y0 = 50;
  const yAxisLength = SVG_VOLUME_HEIGHT - y0 * 2;

  const xAxisY = y0 + yAxisLength;

  return (
    <div>
      <svg width={SVG_VOLUME_WIDTH} height={SVG_VOLUME_HEIGHT}>
        <line x1={x0} y1={xAxisY} x2={x0 + xAxisLength} y2={xAxisY} />
        <text x={x0 + xAxisLength + 5} y={xAxisY + 10}>
          x
        </text>
        <line x1={x0} y1={y0} x2={x0} y2={y0 + yAxisLength} />
      </svg>
    </div>
  );
};

 

- index.css

@tailwind base;
@tailwind components;

.input {
  @apply focus:outline-none focus:border-cyan-600 p-3 border-2  text-lg border-cyan-300 transition-colors;
}

.btn {
  @apply mt-3 text-lg font-medium text-white py-4 bg-cyan-400 hover:bg-cyan-600 transition-colors;
}
text {
  fill: #8b8b8e;
}
line {
  stroke: #8b8b8e;
}
@tailwind utilities;

 

- tailwind.config.js

const colors = require("tailwindcss/colors");
module.exports = {
  purge: ["./src/**/*.{js,jsx,ts,tsx}", "./public/index.html"],
  darkMode: false, // or 'media' or 'class'
  theme: {
    extend: {
      colors: {
        cyan: colors.cyan,
        chartGray: { default: "#17181e" },
        chartLightGray: { default: "#8b8b8e" },
      },
    },
  },
  variants: {},
  plugins: [],
};

 

 

결과물

 

차트 틀이 만들어졌다.

다음 dummy data를 stock.tsx(stockpage)에 만들어준 후, handmade-chart.tsx 에 연결시켜주었다.

 

- stock.tsx

      <div>
        <HandmadeChart
          date={date}
          open={open}
          close={close}
          high={high}
          low={low}
          volume={volume}
        />
      </div>

- handmade-chart.tsx

type Props = {
  date: string[];
  open: number[];
  close: number[];
  high: number[];
  low: number[];
  volume: number[];
};

export const HandmadeChart: React.FC<Props> = ({
  date,
  open,
  close,
  high,
  low,
  volume,
}) => {

  return (
    <div className=" bg-chartGray-default flex-col flex">
      <CandleChart />
      <VolumeChart />
    </div>
  );
};

 

먼저 거래량 차트부터 만들 예정이다.

 

거래량 차트에 가로선을 그어야 하며, 거래량 데이터를 k 값으로 치환해야했다.

따라서 다음과 같이 작성했다.  (css는 따로 언급하지 않겠다.)

 

import React from "react";

const SVG_CHART_WIDTH = 1600;
const SVG_CHART_HEIGHT = 400;
const SVG_VOLUME_WIDTH = 1600;
const SVG_VOLUME_HEIGHT = 400;

// date, open, close, high, low, volume

type Props = {
  date: string[];
  open: number[];
  close: number[];
  high: number[];
  low: number[];
  volume: number[];
};

type VolumeProps = {
  date: string[];
  volume: number[];
};

export const HandmadeChart: React.FC<Props> = ({
  date,
  open,
  close,
  high,
  low,
  volume,
}) => {
  return (
    <div className=" bg-chartGray-default flex-col flex">
      <CandleChart />
      <VolumeChart date={date} volume={volume} />
    </div>
  );
};

const CandleChart = () => {
  const x0 = 150;
  const xAxisLength = SVG_CHART_WIDTH - x0 * 2;
  const y0 = 50;
  const yAxisLength = SVG_CHART_HEIGHT - y0 * 2;

  const xAxisY = y0 + yAxisLength;

  return (
    <div>
      <svg width={SVG_CHART_WIDTH} height={SVG_CHART_HEIGHT}>
        <line x1={x0} y1={xAxisY} x2={x0 + xAxisLength} y2={xAxisY} />
        <text x={x0 + xAxisLength + 5} y={xAxisY + 10}>
          x
        </text>
        <line
          x1={SVG_VOLUME_WIDTH - x0}
          y1={y0}
          x2={x0 + xAxisLength}
          y2={y0 + yAxisLength}
        />
      </svg>
    </div>
  );
};

const VolumeChart: React.FC<VolumeProps> = ({ date, volume }) => {
  const x0 = 150;
  const xAxisLength = SVG_VOLUME_WIDTH - x0 * 2;
  const y0 = 50;
  const yAxisLength = SVG_VOLUME_HEIGHT - y0 * 2;

  const xAxisY = y0 + yAxisLength;
  const dateVolume: [string, number][] = [];
  for (let i = 0; i < date.length; i++) {
    dateVolume.push([date[i], volume[i]]);
  }

  // 배열.reduce((누적값, 현잿값, 인덱스, 요소) => { return 결과 }, 초깃값);

  const dataYMax = dateVolume.reduce(
    (max, [_, dataY]) => Math.max(max, dataY),
    -Infinity
  );
  const dataYMin = dateVolume.reduce(
    (min, [_, dataY]) => Math.min(min, dataY),
    Infinity
  );
  const dataYRange = dataYMax - dataYMin;
  const numYTicks = 5;
  const barPlotWidth = xAxisLength / dateVolume.length;
  // const testYMax = dateVolume.map((item) => Math.max.apply(item[1]), Infinity);

  console.log(dataYMax);
  return (
    <div>
      <svg width={SVG_VOLUME_WIDTH} height={SVG_VOLUME_HEIGHT}>
        <line x1={x0} y1={xAxisY} x2={x0 + xAxisLength} y2={xAxisY} />
        <text x={x0 + xAxisLength + 20} y={xAxisY + 10}>
          거래량
        </text>
        {/* Volume axis */}
        {/* <line x1={x0} y1={y0} x2={x0} y2={y0 + yAxisLength} stroke="grey" /> */}

        {/* 가로선 작성(css name => lineLight) */}
        {Array.from({ length: numYTicks }).map((_, index) => {
          const y = y0 + index * (yAxisLength / numYTicks);
          const yValue = Math.round(
            dataYMax - index * (dataYRange / numYTicks)
          );
          return (
            <g key={index}>
              <line
                className="lineLight"
                x1={SVG_VOLUME_WIDTH - x0}
                x2={x0}
                y1={y}
                y2={y}
                stroke="gray"
              />
              <text x={x0 + xAxisLength + 70} y={y + 5} textAnchor="end">
                {/* volume 값 k로 치환 */}
                {Math.abs(yValue) > 999
                  ? Math.sign(yValue) *
                      (Math.round(Math.abs(yValue) / 100) / 10) +
                    "k"
                  : Math.sign(yValue) * Math.abs(yValue)}
                {/* {yValue} */}
              </text>
            </g>
          );
        })}
        <line
          x1={SVG_VOLUME_WIDTH - x0}
          y1={y0}
          x2={x0 + xAxisLength}
          y2={y0 + yAxisLength}
        />
        {/* <text x={x0 + xAxisLength + 3} y={xAxisY - 280}>
          {Math.max.apply(null, volume)}
        </text> */}
      </svg>
    </div>
  );
};

barPlotWidth 는 아직 사용하지 않았지만, 차트의 너비를 나타낸다.

데이터 갯수마다 크기를 다르게 하기 위해 xAxisLength / dateVolume.length 로 구현했다.

 

그 결과 이런 화면을 그릴 수 있게 된다.

 

가로선 색상은 옅게 했다.

 

다음 필요한 건 Bar Chart 이다. 드디어 barPlotWidth 를 사용할 수 있게 되었다.

여기서 사용할 것은 rect 이다.

<rect> 요소는 rectangle 을 그리는 SVG 기본 모형이고 코너의 위치와 폭과 높이에 따라 사각형을 만드는데 사용된다. 

또한 모서리가 둥근 사각형을 만들 수 있다. <ref : rect docs>

 

그래프를 그리기 위해 다음처럼 작성했다.

 

   {dateVolume.map(([day, dataY], index) => {
          // x는 바 위치
          const x = x0 + index * barPlotWidth;
          const yRatio = (dataY - dataYMin) / dataYRange;
          // y는 바 길이 측정용
          const y = y0 + (1 - yRatio) * yAxisLength;
          const height = yRatio * yAxisLength;

          const sidePadding = 5;
          return (
            <g key={index}>
              <rect
              	fill="red"
                x={x + sidePadding / 2}
                y={y}
                width={barPlotWidth - sidePadding}
                height={height}
              ></rect>
              <text
                x={x + barPlotWidth / 2}
                y={xAxisY + 16}
                textAnchor="middle"
              >
                {day}
              </text>
            </g>
          );
        })}

 

sidePadding 의 경우 css의 padding이라기보단 x 값(위치)과 width 값(너비)에 영향을 주어

padding값을 주기 위한 변수이다.

 

그 결과

 

이렇게 그래프가 나온다.

여기서 문제가 생겼는데, 20200101 날짜에 data가 들어오지 않았다.

그리고 문제를 확인해 본 결과

const yRatio = (dataY - dataYMin) / dataYRange;

부분이 잘못되었다. dataY -dataYMin 이면 최소값은 무조건 0이 되기 때문이다.

따라서 수정이 필요했다.

 

가장 좋은 방법은 dataYMin이 0이여도 되는 데이터가 있는 방법이다.

가령, 1980년도부터 주식시장에 있었던 기업들은 거래량이 1,000도 안됬을 때가 있다.

하지만 내가 가져온 주식 데이터는 2020 8월부터 2021 6월 까지의 데이터이므로

일단 if 문으로 수정시켜주었다.

수정시킨 내용은 다음과 같다.

          let yRatio = 0;
          const yRatioGenerator = () => {
            yRatio = (dataY - dataYMin) / dataYRange;
            if (yRatio > 0) {
              return yRatio;
            } else return (yRatio = dataY / dataYRange / 2);
          };

yRatio가 0일 경우, dataY/dataYRange/2 를 yRatio 로 하라는 것인데,

이럴 경우 최소값이 시각적으로 잘 나온다. (아주 정확한 수치까지 그래프가 도달하진 못하지만)

 

그러면, 

 

 

잘 나오는걸 확인할 수 있다.

 

이젠 dummy 데이터가 아닌 실제 데이터를 가져와준다.

 

 

잘 나오는 것이 확인되었다.

마지막으로 거래량 차트의 날짜를 격주별로 나타내게 해야한다.

이 작업은 캔들차트 작업할 때 같이 해야겠다.