JavaScript와 CSS만으로 구현하는 이미지 파일로 지도 기능 만들기! (드래그, 확대, 축소 / in Next.js client component) - SoA 개발 일지

최은우·2023년 12월 11일
0

Story of Arda

목록 보기
3/3

SoA 시리즈의 앞선 글에서 말씀드렸다 싶이 반지의 제왕의 지도를 확대, 축소, 드래그 하여 지도의 각 지역을 탐색하고 클릭시 해당 지역에 대한 정보를 보여주는 대시보드 화면을 구현 중이였습니다.

저는 JavaScript와 CSS만으로 구현하고자 하였습니다.
이유는
1. JavaScript와 CSS사용에 익숙해지고 역량을 향상시키기 위해
2. 기능, UI를 모듈화하고 아토믹 패턴을 적용하여 개발하는 등 직접 유지보수성을 높이기 위해

CSS에 대한 깊이가 부족하여 '뻘짓'을 한 코드일 수 있지만 결과물은 괜찮았습니다...ㅠㅠ


초기 상태

먼저 이미지가 들어갈 div태그를 만들어주고 자식 태그로 img태그를 만들어 줍니다.
(Next.js의 Image태그를 쓰지 않은 이유는 이전 게시글에 기재되어있습니다.)

export default function HomeMap({ mapData }: { mapData: MapType}) {
  const imgRef = useRef<HTMLImageElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);
  const [scale, setScale] = useState<number>(1);
  const [dragging, setDragging] = useState<boolean>(false);
  const [mapPosition, setMapPosition] = useState<{ x: number, y: number }>({ x: 0, y: 0 });
  const [mouseStart, setMouseStart] = useState<{ x: number, y: number }>({ x: 0, y: 0 });
  const [mapTopLeft, setMapTopLeft] = useState<{ x: number, y: number}>({ x: 0, y: 0 });
  const boxStyle = {
    backgroundColor: '#D9D9D9',
    width: mapData.width,
    height: mapData.height,
    left: mapData.left,
    top: mapData.top,
    boxSizing: 'border-box' as 'border-box',
    borderRadius: '20px',
    position: 'absolute' as 'absolute',
    overflow: 'hidden',
  };

  const zoomButtonStyle = {
    position: 'absolute' as 'absolute',
    left: '10px',
    bottom: '10px'
  };

  const mapStyle = {
    objectFit: 'cover' as 'cover',
    width: '100%',
    position: 'absolute' as 'absolute',
    transform: `scale(${scale})`,
    cursor: dragging ? 'move': 'default',
    left: `${mapPosition.x}px`,
    top: `${mapPosition.y}px`
  }

return (
    <div
      style={boxStyle}
      ref={containerRef}
    >
      <img
        ref={imgRef}
        id="map"
        src="/SoA.jpeg" 
        alt="Home Map Image"
        style={mapStyle}
        onClick={handleMapClick}
        onDragStart={(e) => e.preventDefault()}
        onMouseDown={handleMouseDown}
        onMouseUp={handleMouseUp}
        onMouseMove={handleMouseMove}
      />
      <div style={zoomButtonStyle}>
        <button onClick={handleZoomIn}>+</button>
        <button onClick={handleZoomOut}>-</button>
      </div>
    </div>
  )

상위 div태그를 넘어가는 부분은 안보이게 하기 위해
overflow: 'hidden' 속성을 추가해줍니다.

이미지 파일을 div태그 내에 가로 길이를 기준으로 맞춰주기 위해
objectFit: 'cover' as 'cover', width: '100%', 속성을 추가해줍니다.

onDragStart={(e) => e.preventDefault()}

드래그 시 혹시 모를 디폴트 이벤트를 방지해주기 위해 preventDefault를 설정해줍니다.

onMouseDown={handleMouseDown}

이미지 위에서 마우스 왼쪽을 누를 시 드래그 시작을 알리기 위한 이벤트 설정용 입니다.

onMouseMove={handleMouseMove}

마우스를 누른 채로 마우스를 이동할 시 (드래그 할 시) 이미지의 포지션을 업데이트하기 위한 이벤트 핸들러입니다.

onMouseUp={handleMouseUp}

드래그가 끝났을 때, 즉 마우스 왼쪽을 누른 상태에서 뗏을 때 드래그 끝남을 알리기 위한 이벤트 핸들러입니다.

onClick={handleZoomIn}

한 칸 확대를 하였을 때 이미지의 scale을 바꾸고 이미지의 상위 div태그에 대한 상대적 포지션을 다시 계산하기 위한 핸들러입니다.
상대적 포지션을 다시 계산하는 이유는 태그의 scale을 변경 시 실제 브라우저에서의 absolute position의 값은 변하게 되지만 드래그 시 포지션을 업데이트 할 때 사용하는 상대적 포지션 값인 position state값은 변하지 않기 때문입니다.
밑에서 자세하게 설명하겠습니다.

onClick={handleZoomOut}

한 칸 축소 하였을 때 이미지의 scale을 바꾸고 이미지의 상위 div태그에 대한 상대적 포지션 재 계산, 이미지가 상위 div태그에서 지나치게 이상한 곳을 축소되었을 때 div태그 안에 꽉 차게 보이게 하기 위한 이벤트 핸들러
역시 밑에서 자세하게 설명하겠습니다.


1. handleMouseDown

  const handleMouseDown = (e: React.MouseEvent<HTMLImageElement, MouseEvent>) => {
    setDragging(true);
    if (imgRef.current) {
      const rect = imgRef.current.getBoundingClientRect();
      setMouseStart({ x: e.clientX - rect.left, y: e.clientY - rect.top });
    }
  };
  • setDragging(true)
    드래깅을 시작하겠다는 state를 설정하기 위한 코드입니다.

  • const rect = imgRef.current.getBoundingClientRect();
    getBoundingClientRect는 엘리먼트의 크기와 뷰포트에서의 상대적 위치를 반환해주는 메서드입니다.
    즉, 브라우저 화면을 스크롤 하게 되면 getBoundingClientRect이 반환하는 상대적 위치가 달라지게됩니다.
    하지만 저는 지도 화면을 스크롤이 필요없는 대시보드 형태로 구성할 것이기 때문에 지도 이미지의 상대적 위치를 안정적으로 계산할 수 있는 최적의 메서드인 getBoundingClientRect를 채택하였습니다.
    getBoundingClientRect MDN 문서 보러가기

  • setMouseStart({ x: e.clientX - rect.left, y: e.clientY - rect.top });
    mouseStart state는 드래그를 시작할 시 마우스가 얼마나 이동했는지를 계산하기 위해 사용하는 상태 변수입니다.
    마우스가 시작한 뷰포트에서의 위치를 저장하게 되는데 드래그 중 마우스가 멈춘 위치와 mouseStart에 저장된 시작 위치의 차이를 계산하면 이미지의 포지션을 어떻게 옮겨야 할지 계산할 수 있는 것이죠

  • e.clientX - rect.left
    e.clientX는 뷰포트에서의 커서의 상대적 위치를 의미합니다.
    rect.left는 이미지의 왼쪽 경계의 뷰포트에서의 상대적 위치를 의미합니다.
    즉, e.clientX - rect.left는 이미지 내에서의 커서의 상대적 위치를 계산한 것이 됩니다.


2. handleMouseMove

  const handleMouseMove = (e: React.MouseEvent<HTMLImageElement, MouseEvent>) => {
    if (dragging && imgRef.current) {
      const rect = imgRef.current.getBoundingClientRect();
      const [rw, rh] = [Math.floor(rect.width), Math.floor(rect.height)];
      let newX: number;
      let newY: number;

      if (mapPosition.x + (e.clientX - rect.left - mouseStart.x) > mapTopLeft.x) {
        newX = mapTopLeft.x;
      } else if (mapPosition.x + (e.clientX - rect.left - mouseStart.x) < mapTopLeft.x -(rw - mapData.width)) {
        newX = mapTopLeft.x -(rw - mapData.width);
      } else {
        newX = mapPosition.x + (e.clientX - rect.left - mouseStart.x);
      }

      if (mapPosition.y + (e.clientY - rect.top - mouseStart.y) > mapTopLeft.y) {
        newY = mapTopLeft.y;
      } else if (mapPosition.y + (e.clientY - rect.top - mouseStart.y) < mapTopLeft.y -(rh - mapData.height)) {
        newY = mapTopLeft.y -(rh - mapData.height)
      } else {
        newY = mapPosition.y + (e.clientY - rect.top - mouseStart.y);
      }

      setMapPosition({ x: newX, y: newY });
    }
  }
  • mapPosition.x + (e.clientX - rect.left - mouseStart.x) > mapTopLeft.x
    mapPosition.x는 div태그 안에서의 이미지의 위치를 계산하기 위한 state변수입니다.
    e.clientX - rect.left는 현재 커서의 이미지 내에서의 상대적 위치입니다. 여기에 mouseStart.x를 빼게 되면 드래그를 시작할 때의 커서의 상대적 위치와 현재의 커서의 상대적 위치의 차이, 즉 어느 방향으로 얼마나 움직였는지를 계산한 것이 됩니다.

0개의 댓글