본문 바로가기
🍿 Project

React로 Slider 구현 & React에서 Canvas로 곡선 그래프 그리기 (+globalCompositeOperation )

by Tamii 2021. 6. 2.
반응형

Jenny와 airbnb사이트 프로젝트를 진행하며 요금부분의 그래프를 구현한 내용입니다.

무엇보다도 canvas와 slider구현을 위한 엄청난 수학적 계산을 해주신 Jenny에게 이글을 바칩니다.. 🌹


폴더 구조는 이렇게 되어있는데 

아래 Slider 과 ChartCanvas로 나눠서 구성했다.

 

 

📈  Slider

 

Slider에는 총 2개의 상태가 있다.

const { priceData, priceDispatch } = useContext(SearchContext);
const [avg, setAvg] = useState((min + max) / 2);
const [min, max] = [0, 1000000];

priceData  : slider 가 가리키는 min,max 값  / useContext로 관리  

avg : slider로 바꿀 때마다의 min max의 평균 값 / use State 로 관리

min max = 처음 그릴 그래프의 min, max 값   ( state 말고 변수로 관리 )

 

사실 원래 슬라이드의 Thumb(사진의 동그라미)는 하나인데 

기획서상에서는 두개가 필요하므로 두 slider를 이어붙였다고 볼 수 있다.

 

전달 값 

  const thumbSize = 24;
  const width = 365;
  const leftWidth =
    thumbSize + ((avg - min) / (max - min)) * (width - 2 * thumbSize);

  const rightWidth =
    thumbSize + ((max - avg) / (max - min)) * (width - 2 * thumbSize);

  const leftPercent = ((priceData.minPrice - min) / (avg - min)) * 100;
  const rightPercent = ((priceData.maxPrice - avg) / (max - avg)) * 100;

 

Slider Component

    <SliderDiv>
      <LeftInput
        type="range"
        min={min}
        max={avg}
        leftWidth={leftWidth}
        leftPercent={leftPercent}
        value={priceData.minPrice}
        onChange={({ target }) => handleMinSliderChange({ target })}
      />
      <RightInput
        type="range"
        min={avg}
        max={max}
        leftWidth={leftWidth}
        rightWidth={rightWidth}
        rightPercent={rightPercent}
        value={priceData.maxPrice}
        onChange={({ target }) => handleMaxSliderChange({ target })}
      />
    </SliderDiv>

각각의 Input 에게 계산한  값들을 전달하여 slider를 구성한다.

LeftInput  - left: 0  / width: leftWidth / leftPercent

RightInput  - left: leftWidht / width : rightWidth / rightPercent

const LeftInput = styled.input`
  position: absolute;
  left: 0;
  width: ${({ leftWidth }) => `${leftWidth}px`};
  -webkit-appearance: none;
  background-image: linear-gradient(
    to right,
    #e5e5e5 0%,
    #e5e5e5 ${({ leftPercent }) => `${leftPercent}%`},
    #333333 ${({ leftPercent }) => `${leftPercent}%`},
    #333333 100%
  );
`;
const RightInput = styled.input`
  position: absolute;
  left: ${({ leftWidth }) => `${leftWidth}px`};
  width: ${({ rightWidth }) => `${rightWidth}px`};
  -webkit-appearance: none;
  background-image: linear-gradient(
    to right,
    #333333 0%,
    #333333 ${({ rightPercent }) => `${rightPercent}%`},
    #e5e5e5 ${({ rightPercent }) => `${rightPercent}%`},
    #e5e5e5 100%
  );
`;

 전달받은 값을 이용해 CSS 를 설정하며 렌더링이 되는 방식

 


📈 Canvas 그래프

❖ 상태

Cnavas 에는 총 2개의 상태가 와 1개의 useRef를 가지고 있다.

  const { priceData } = useContext(SearchContext);
  const [chart, setChart] = useState({ width: 0, height: 0 });
  const chartRef = useRef();

priceData  : slider 가 가리키는 min,max 값  / useContext로 관리 

chart  : chart의 width, height 값  / useState로 관리  

charRef = CartCanvas 자체 ( useRef로 관리 )

 

Jenny와 페어프로그래밍으로 진행하며 많이 고민했던 부분인데, 

일단은 기본적으로 Jenny가 차트를 어떤 방식으로 그릴지 어느정도 구성을 한 상태에서 

미세한 부분이 어긋나  차트가 정상적으로 보이지 않고 있었다.

 

❖ 구현 방법

일단 drawChart를 통해  그래프를 그리고

drawOver로 그린 사각형으로 해당 부분을 감싸며 globalCompositeOperation 을 통해  canvas연산을 통해 나오게 한다.

 

❖ 코드

 

drawChart 함수

const drawChart = (ctx, points) => {
    ctx.beginPath();
    ctx.moveTo(points[0].x, points[0].y);

    for (var i = 0; i < points.length - 1; i++) {
      var x_mid = (points[i].x + points[i + 1].x) / 2;
      var y_mid = (points[i].y + points[i + 1].y) / 2;
      var cp_x1 = (x_mid + points[i].x) / 2;
      var cp_x2 = (x_mid + points[i + 1].x) / 2;

      ctx.quadraticCurveTo(cp_x1, points[i].y, x_mid, y_mid);

      ctx.quadraticCurveTo(
        cp_x2,
        points[i + 1].y,
        points[i + 1].x,
        points[i + 1].y
      );
      ctx.strokeStyle = 'lightGray';
      ctx.stroke();
    }

    ctx.fillStyle = '#E5E5E5';
    ctx.fill();
  };

drawOver 함수

  const drawOver = (ctx, minPrice, maxPrice) => {
    //
    ctx.globalCompositeOperation = 'source-atop';
    ctx.fillStyle = '#333';
    let leftWidth = (minPrice * chart.width) / 1000000;
    const rightWidth = (maxPrice * chart.width) / 1000000;
    ctx.beginPath();
    ctx.moveTo(0, 0);
    ctx.lineTo(0, chart.height);
    ctx.lineTo(leftWidth, chart.height);
    ctx.lineTo(leftWidth, 2);
    ctx.lineTo(rightWidth, 2);
    ctx.lineTo(rightWidth, chart.height);
    ctx.lineTo(chart.width, chart.height);
    ctx.lineTo(0, chart.width);
    ctx.lineTo(0, 0);

    ctx.fill();
  };

함수실행 및 렌더링

const ctx = chartRef.current?.getContext('2d');
  ctx && chart.height && chart.width && drawChart(ctx, points);
  ctx && drawOver(ctx, priceData.minPrice, priceData.maxPrice);
  return (
    <PriceChartViewDiv>
      <ChartCanvas width="365px" id="canvas" ref={chartRef}></ChartCanvas>
      <Slider />
    </PriceChartViewDiv>
  );

 

❖ 시행착오

 

1) drawOver을 두개로 생성

  const drawMinOver = (ctx, minPrice, maxPrice) => {
    ctx.globalCompositeOperation = 'destination-atop';
    ctx.fillStyle = 'black';
    let leftWidth = (minPrice * chart.width) / 1000000;
    const rightWidth = (maxPrice * chart.width) / 1000000;
    ctx.fillRect(1, 1, leftWidth, chart.height - 1);
    ctx.fill();
  };

  const drawMaxOver = (ctx, minPrice, maxPrice) => {
    ctx.globalCompositeOperation = 'destination-atop';
    ctx.fillStyle = 'black';
    const rightWidth = (maxPrice * chart.width) / 1000000;
    ctx.fillRect(rightWidth, 1, chart.width - 1, chart.height - 1);
    ctx.fill();
  };

처음엔 over 사각형 두개를 만들어 두고  두개를 동시에 연산하려고 했지만 연산이 먹지 않았다...

 

<- 고통의 그림

 

오랜 고민끝에 나온 아이디어

바로 drawOver를 lineTo로 다 그려버리자!!!! 

 해서 나온 뿌듯한 합작이다.

 

 

 

 

 

2) useEffect 에서 렌더링 관리

 

수정 전

 useEffect(() => {
    setChart({
      width: chartRef.current.width,
      height: chartRef.current.height,
    });
    const ctx = chartRef.current.getContext('2d');
    chart.height && chart.width && drawChart(ctx, points);
    drawOver(ctx, priceData.minPrice, priceData.maxPrice);
  }, [chart.height, priceData.maxPrice, priceData.minPrice]);

  return (
    <PriceChartViewDiv>
      <ChartCanvas width="365px" ref={chartRef}></ChartCanvas>
      <Slider />
    </PriceChartViewDiv>
  );
};

수정 후

useEffect(() => {
    setChart({
      width: chartRef.current.width,
      height: chartRef.current.height,
    });
  }, [chart.height]);

  const ctx = chartRef.current?.getContext('2d');
  ctx && chart.height && chart.width && drawChart(ctx, points);
  ctx && drawOver(ctx, priceData.minPrice, priceData.maxPrice);
  return (
    <PriceChartViewDiv>
      <ChartCanvas width="365px" id="canvas" ref={chartRef}></ChartCanvas>
      <Slider />
    </PriceChartViewDiv>
  );
};

useEffect에서 canvas를 그리는걸 진행했다.

사실 이 canvas연산은 drawChart가 그려진 후 -> drawOver가 그려질때 연산이 되는 방식이라

canvas가 그려지는 순서가 중요했는데 useEffect안에서 그려지니

useEffect의 dependency요소인 priceData 가 변경될때마다 drawChart가 재 렌더링 되버려 연산이 제대로 동작하지 않았다.

 

다시한번 명심하자 useEffect에는 렌더링 요소가 들어가면 안된다.

 

 

📌 globalCompositeOperation 

캔버스의 도형을 그릴 때 순서를 제한하며 도형합성을 하는 방법

 

 출처: https://m.blog.naver.com/PostView.naver?blogId=javaking75&logNo=140170321918&proxyReferer=https:%2F%2Fwww.google.com%2F

 

이렇게 canvas도형을 만들 때 다양한 연산이 가능하다

 

댓글