Act99 기술블로그

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

개발팁저장소/react

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

Act99 2021. 11. 30. 18:33

먼저 오늘 한 결과물은 이렇다.

완성본

 

저번 시간에는 차트구현까지는 했으나, 데이터 값 대비 차트 구현이 정확하지 않았으며,

화면 리사이즈를 할 때 오류가 생겼다. (몇개의 데이터만 변화)

 

따라서 유지보수에도 편하고, 화면 리사이즈 시 자연스럽게 변하며, 차트를 좀 더 정밀하게 구현할 필요가 있었다.

 

먼저, 화면 리사이즈부터 다루었다.

ReactHook 을 이용해 사이즈 변화상태마다 width, height 값을 구할 예정이다.

(tailwindCSS 를 사용하면 편하지만, 정확한 width 값과 height 값이 필요했으며, Hook 연습도 필요했기 때문에 직접 구현했다.)

 

Hook Docs를 보면 useWindowSize 를 이용하는 코드가 나와있다.

(공부를 목적으로 하면 직접 타이핑하면서 작성할 것을 추천드립니다.)

interface Size {
  width: number;
  height: number;
}

function useWindowSize(): Size {
  const [windowSize, setWindowSize] = useState<Size>({
    width: 0,
    height: 0,
  });
  useEffect(() => {
    // Handler to call on window resize
    function handleResize() {
      // Set window width/height to state
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }
    // Add event listener
    window.addEventListener("resize", handleResize);
    // Call handler right away so state gets updated with initial window size
    handleResize();
    // Remove event listener on cleanup
    return () => window.removeEventListener("resize", handleResize);
  }, []); // Empty array ensures that effect is only run on mount
  return windowSize;
}

 

다음 페이지에 useWindowSize 함수를 넣고 size 값을 차트에 보내주었다.

 

export const Stock = () => {
  const size: Size = useWindowSize();
  
  ...
  ...
  ...
  ...
  
   <div>
        <HandmadeChart
          width={size.width}
          height={size.height}

 

그다음 먼저 작업한 것은 기존 차트의 size를 반응형으로 바꿔줄 차례이다.

console.log(width, height)을 한 결과

undefined 가 종종 발생했기 때문에 type이 number 가 아니면 0으로 변환시켜주었다.

또한 세로선을 12개 만들어주어 date 를 구현시켰다.

 

세로선 당 date값을 구해주는 함수는 다음과 같다.

  const xValue: string[] = [];
  const generateDate = () => {
    console.log(date[Math.round(9.121345)]);
    for (let i = 0; i < 12; i++) {
      xValue.push(date[Math.round(date.length / 12) * i]);
    }
    // xValue.reverse();
    // console.log(xValue);
    return xValue;
  };
  generateDate();

 

후에, 마우스 scroll 을 할 때 데이터 값을 array에 추가하고 빼서 차트의 확대 축소 기능을 구현할 예정이며,

 

날짜는 총 데이터 중 12개의 데이터를 특정 간격으로 추출했는데,

최대 축소는 12개의 데이터 값으로 할 예정이기 때문에  12번 반복하는 반복문을 사용했다.

 

그 다음, 해외 비트코인 거래소 차트를 보니 날짜의 폰트사이즈는 화면이 아주 작아지면 글자가 같이 작아지지만,

금액의 폰트사이즈는 일정했다.

따라서 이 역시 구현시켰다.

 

그 코드는 다음과 같다.

 

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

export const HandmadeChart: React.FC<Props> = ({
  width,
  height,
  date,
  open,
  close,
  high,
  low,
  volume,
  name,
}) => {
  // const [windowSize, setWindowSize] = useState({
  //   width: window.innerWidth,
  //   height: window.innerHeight,
  // });
  // const handleResize = () => {
  //   setWindowSize({ width: window.innerWidth, height: window.innerHeight });
  // };
  // useEffect(() => {
  //   window.addEventListener("resize", handleResize);
  //   return () => {
  //     window.removeEventListener("resize", handleResize);
  //   };
  // }, []);

  return (
    <div className=" bg-chartGray-default flex-col flex">
      <h3 className="text-white"> {width}</h3>
      <h3 className="text-white"> {height}</h3>

      <CandleChart
        width={width}
        height={height}
        date={date}
        open={open}
        close={close}
        high={high}
        low={low}
        name={name}
      />

      {/* <VolumeChart date={date} volume={volume} />  */}
    </div>
  );
};

type CandleStickProps = {
  width: number;
  height: number;
  date: string[];
  open: number[];
  close: number[];
  high: number[];
  low: number[];
  name: string[];
};

const CandleChart: React.FC<CandleStickProps> = ({
  width,
  height,
  date,
  open,
  close,
  high,
  low,
  name,
}) => {
  let SVG_CHART_WIDTH = typeof width === "number" ? width * 1 : 0;
  let SVG_CHART_HEIGHT = typeof height === "number" ? height * 0.5 : 0;
  const x0 = SVG_CHART_WIDTH * 0.015;
  const y0 = SVG_CHART_HEIGHT * 0.015;
  const xForPrice = 75;
  const xAxisLength = SVG_CHART_WIDTH - xForPrice;
  const yAxisLength = SVG_CHART_HEIGHT * 0.94;

  // const xAxisY = y0 + yAxisLength;

  const dataArray: [string, number, number, number, number][] = [];
  for (let i = 0; i < date.length; i++) {
    dataArray.push([date[i], open[i], close[i], high[i], low[i]]);
  }
  console.log(dataArray);
  const dataYMax = dataArray.reduce(
    (max, [_, open, close, high, low]) => Math.max(max, high),
    -Infinity
  );
  console.log(dataYMax);
  const dataYMin = dataArray.reduce(
    (min, [_, open, close, high, low]) => Math.min(min, low),
    +Infinity
  );

  const dateMax = dataArray.reduce(
    (max, [date, open, close, high, low]) => Math.max(max, parseInt(date)),
    -Infinity
  );
  const dateMin = dataArray.reduce(
    (min, [date, open, close, high, low]) => Math.min(min, parseInt(date)),
    +Infinity
  );
  const dateRange = dateMax - dateMin;

  console.log(dataYMin);
  const dataYRange = dataYMax - dataYMin;
  const numYTicks = 7;
  const numXTicks = 12;
  const barPlothWidth = xAxisLength / dataArray.length;

  const xValue: string[] = [];
  const generateDate = () => {
    console.log(date[Math.round(9.121345)]);
    for (let i = 0; i < 12; i++) {
      xValue.push(date[Math.round(date.length / 12) * i]);
    }
    // xValue.reverse();
    // console.log(xValue);
    return xValue;
  };
  generateDate();
  return (
    <div>
      <svg width={SVG_CHART_WIDTH} height={SVG_CHART_HEIGHT}>
        <line
          x1={x0}
          y1={yAxisLength - 10}
          x2={xAxisLength}
          y2={yAxisLength - 10}
          stroke="gray"
        />
        <line
          x1={xAxisLength}
          y1={y0}
          x2={xAxisLength}
          y2={yAxisLength - 10}
          stroke="gray"
        />
        <text
          x={x0 + 15}
          y={y0 + yAxisLength * 0.06}
          fontSize={
            SVG_CHART_WIDTH > 700
              ? SVG_CHART_WIDTH * 0.01
              : SVG_CHART_WIDTH * 0.02
          }
        >
          {name[name.length - 1]}
        </text>
        {/* 세로선 작성 */}
        {Array.from({ length: numXTicks }).map((_, index) => {
          const x = x0 + index * (xAxisLength / numXTicks) + 10;

          return (
            <g key={index}>
              <line
                className="lineLight"
                x1={x}
                x2={x}
                y1={yAxisLength - 10}
                y2={y0}
              ></line>
              <text
                x={x}
                y={SVG_CHART_HEIGHT - 10}
                textAnchor="middle"
                fontSize={SVG_CHART_WIDTH < 800 ? 6 : 10}
              >
                {xValue[index]}
              </text>
            </g>
          );
        })}
        {/* 가로선 작성(css name => lineLight) */}
        {Array.from({ length: numYTicks }).map((_, index) => {
          const y = y0 + index * (yAxisLength / numYTicks) + 10;
          const yValue = Math.round(
            dataYMax - index * (dataYRange / numYTicks)
          );
          return (
            <g key={index}>
              <line
                className="lineLight"
                x1={xAxisLength}
                x2={x0}
                y1={y}
                y2={y}
              ></line>
              <text x={SVG_CHART_WIDTH - 60} y={y} fontSize="12">
                {yValue.toLocaleString()} ₩
              </text>
            </g>
          );
        })}
        {dataArray.map(([day, open, close, high, low], index) => {
          const x = x0 + index * barPlothWidth;
          const sidePadding = 5;
          const max = Math.max(open, close);
          const min = Math.min(open, close);
          const scaleY = scaleLinear()
            .domain([dataYMin, dataYMax])
            .range([y0, yAxisLength]);
          const fill = close > open ? "#4AFA9A" : "#E33F64";
          return (
            <g key={index}>
              <line
                x1={x + sidePadding / 2 + (barPlothWidth - sidePadding) / 2}
                y1={SVG_CHART_HEIGHT - scaleY(high) - y0}
                x2={x + sidePadding / 2 + (barPlothWidth - sidePadding) / 2}
                y2={SVG_CHART_HEIGHT - scaleY(low) - y0}
                stroke={open > close ? "red" : "green"}
                strokeWidth={fill}
              />
              <rect
                {...{ fill }}
                x={x + sidePadding / 2}
                y={SVG_CHART_HEIGHT - scaleY((max + min) / 2)}
                width={barPlothWidth - sidePadding}
                height={scaleY(max - min)}
              ></rect>
              {/* <rect
                  fill={open > close ? "red" : "green"}
                  x={x + sidePadding / 2}
                  y={0}
                  width={1}
                  height={(SVG_CHART_HEIGHT * (high - low)) / dataYMax}
                ></rect> */}
            </g>
          );
        })}
        <line
          x1={SVG_CHART_WIDTH - x0}
          y1={y0}
          x2={x0 + SVG_CHART_WIDTH}
          y2={y0 + SVG_CHART_HEIGHT}
        />
      </svg>
    </div>
  );
};

 

 

1920 사이즈
500사이즈

 

 

다음은 정확한 차트 바를 구현하는 것이었다.

 

여기서 사용한 것이 d3-scale 이다.

 

`npm i d3-scale`
`npm i --save-dev @types/d3-scale`

 

https://www.npmjs.com/package/d3-scale

 

d3-scale

Encodings that map abstract data to visual representation.

www.npmjs.com

 

이 패키지는 (가져온 데이터) -> (내가 사용할 사이즈 범위) 로 치환시킬 때 편리히다.

기본 개념은 

scaleLinear().domain([내가 가져올 데이터의 최소, 내가 가져올 데이터의 최대]).range([내가 사용할(압축시킬) 범위의 최소, 내가 사용할 범위의 최대]) 이다. 그리고 이것으로 캔들차트를 잘 구현시켰다.

 

- 전체코드 (아래에 깃허브 링크를 남기겠습니다.)

 

import React, { useEffect, useState } from "react";
import { scaleLinear, ScaleLinear } from "d3-scale";

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

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

export const HandmadeChart: React.FC<Props> = ({
  width,
  height,
  date,
  open,
  close,
  high,
  low,
  volume,
  name,
}) => {
  // const [windowSize, setWindowSize] = useState({
  //   width: window.innerWidth,
  //   height: window.innerHeight,
  // });
  // const handleResize = () => {
  //   setWindowSize({ width: window.innerWidth, height: window.innerHeight });
  // };
  // useEffect(() => {
  //   window.addEventListener("resize", handleResize);
  //   return () => {
  //     window.removeEventListener("resize", handleResize);
  //   };
  // }, []);

  return (
    <div className=" bg-chartGray-default flex-col flex">
      <h3 className="text-white"> {width}</h3>
      <h3 className="text-white"> {height}</h3>

      <CandleChart
        width={width}
        height={height}
        date={date}
        open={open}
        close={close}
        high={high}
        low={low}
        name={name}
      />

      {/* <VolumeChart date={date} volume={volume} />  */}
    </div>
  );
};

type CandleStickProps = {
  width: number;
  height: number;
  date: string[];
  open: number[];
  close: number[];
  high: number[];
  low: number[];
  name: string[];
};

const CandleChart: React.FC<CandleStickProps> = ({
  width,
  height,
  date,
  open,
  close,
  high,
  low,
  name,
}) => {
  let SVG_CHART_WIDTH = typeof width === "number" ? width * 1 : 0;
  let SVG_CHART_HEIGHT = typeof height === "number" ? height * 0.5 : 0;

  const xForPrice = 75;
  const xAxisLength = SVG_CHART_WIDTH - xForPrice;
  const yAxisLength = SVG_CHART_HEIGHT * 0.94;
  const x0 = 0;
  const y0 = 0;
  // const xAxisY = y0 + yAxisLength;

  const dataArray: [string, number, number, number, number][] = [];
  for (let i = 0; i < date.length; i++) {
    dataArray.push([date[i], open[i], close[i], high[i], low[i]]);
  }
  console.log(dataArray);
  const dataYMax = dataArray.reduce(
    (max, [_, open, close, high, low]) => Math.max(max, high),
    -Infinity
  );
  console.log(dataYMax);
  const dataYMin = dataArray.reduce(
    (min, [_, open, close, high, low]) => Math.min(min, low),
    +Infinity
  );

  const dateMax = dataArray.reduce(
    (max, [date, open, close, high, low]) => Math.max(max, parseInt(date)),
    -Infinity
  );
  const dateMin = dataArray.reduce(
    (min, [date, open, close, high, low]) => Math.min(min, parseInt(date)),
    +Infinity
  );

  console.log(dataYMin);
  const dataYRange = dataYMax - dataYMin;
  const numYTicks = 7;
  const barPlothWidth = xAxisLength / dataArray.length;

  const numXTicks = 12;

  const xValue: string[] = [];
  const generateDate = () => {
    console.log(date[Math.round(9.121345)]);
    for (let i = 0; i < 12; i++) {
      xValue.push(date[Math.round(date.length / 12) * i]);
    }
    // xValue.reverse();
    // console.log(xValue);
    return xValue;
  };
  generateDate();
  return (
    <div>
      <svg width={SVG_CHART_WIDTH} height={SVG_CHART_HEIGHT}>
        <line
          x1={x0}
          y1={yAxisLength}
          x2={xAxisLength}
          y2={yAxisLength}
          stroke="gray"
        />
        <line
          x1={xAxisLength}
          y1={y0}
          x2={xAxisLength}
          y2={yAxisLength}
          stroke="gray"
        />
        <text
          x={x0 + 15}
          y={y0 + yAxisLength * 0.06}
          fontSize={
            SVG_CHART_WIDTH > 700
              ? SVG_CHART_WIDTH * 0.01
              : SVG_CHART_WIDTH * 0.02
          }
        >
          {name[name.length - 1]}
        </text>
        {/* 세로선 작성 */}
        {Array.from({ length: numXTicks }).map((_, index) => {
          const x = x0 + index * (xAxisLength / numXTicks) + 10;

          return (
            <g key={index}>
              <line
                className="lineLight"
                x1={x}
                x2={x}
                y1={yAxisLength}
                y2={y0}
              ></line>
              <text
                x={x}
                y={SVG_CHART_HEIGHT}
                textAnchor="middle"
                fontSize={SVG_CHART_WIDTH < 800 ? 6 : 10}
              >
                {xValue[index]}
              </text>
            </g>
          );
        })}
        {/* 가로선 작성(css name => lineLight) */}
        {Array.from({ length: numYTicks }).map((_, index) => {
          const y = y0 + index * (yAxisLength / numYTicks) + 10;
          const yValue = Math.round(
            dataYMax - index * (dataYRange / numYTicks)
          );
          return (
            <g key={index}>
              <line
                className="lineLight"
                x1={xAxisLength}
                x2={x0}
                y1={y}
                y2={y}
              ></line>
              <text x={SVG_CHART_WIDTH - 60} y={y} fontSize="12">
                {yValue.toLocaleString()} ₩
              </text>
            </g>
          );
        })}
        {dataArray.map(([day, open, close, high, low], index) => {
          const x = x0 + index * barPlothWidth;
          const sidePadding = xAxisLength * 0.0015;
          const max = Math.max(open, close);
          const min = Math.min(open, close);

          const scaleY = scaleLinear()
            .domain([dataYMin, dataYMax])
            .range([y0, yAxisLength]);
          const fill = close > open ? "#4AFA9A" : "#E33F64";
          console.log(scaleY(max));
          console.log(scaleY(min));
          return (
            <g key={index}>
              <line
                x1={x + xAxisLength * 0.002}
                y1={yAxisLength - scaleY(low)}
                x2={x + xAxisLength * 0.002}
                y2={yAxisLength - scaleY(high)}
                stroke={open > close ? "red" : "green"}
              />
              <rect
                {...{ fill }}
                x={x}
                y={yAxisLength - scaleY(max)}
                width={barPlothWidth - sidePadding}
                // 시가 종가 최대 최소값의 차
                height={scaleY(max) - scaleY(min)}
              ></rect>
              {/* <rect
                  fill={open > close ? "red" : "green"}
                  x={x + sidePadding / 2}
                  y={0}
                  width={1}
                  height={(SVG_CHART_HEIGHT * (high - low)) / dataYMax}
                ></rect> */}
            </g>
          );
        })}
      </svg>
    </div>
  );
};

 

1920

 

500

 

 

 

 

이젠 스크롤로 데이터를 축소, 증가 시키는 작업을 실행할 것이다.

 

 

 

github: https://github.com/act99/stock-frontend

 

GitHub - act99/stock-frontend: Stock Chart & Livechat on website. For education & testing

Stock Chart & Livechat on website. For education & testing - GitHub - act99/stock-frontend: Stock Chart & Livechat on website. For education & testing

github.com