nextjs/웹사이트 만들기

산정보 보기 웹사이트 만들기(4) - 지도 꾸미기

lamarcK 2025. 4. 14. 20:11

본격적으로 지도를 꾸며서 hover가 의미있도록 하려고 한다.

fetch를 통해 불러온 것이기 때문에 직접 onclick 이벤트를 부여하면서 hover 효과도 같이 부여한다.

**MapComponent.tsx
  useEffect(() => {
    const clickHandlers = new Map();
    fetch('/Map_of_South_Korea-blank.svg')
      .then((res) => res.text())
      .then((svgText) => {
        if (containerRef.current) {
          containerRef.current.innerHTML = svgText;
          const paths = containerRef.current.querySelectorAll('path');
          paths.forEach((path) => {
			// 기본 스타일 적용
              path.style.fill = '#d3d3d3';
              path.style.transition = 'fill 0.3s ease, opacity 0.3s ease';
              path.style.cursor = 'pointer';
              path.style.opacity = '1';

            // 호버 이벤트 추가
              path.addEventListener('mouseenter', () => {
            // 모든 경로를 순회하며 처리 (클로저의 'paths' 사용)
              paths.forEach(p => {
                if (p === path) {
                  // 현재 호버된 경로
                  p.style.fill = '#00FFFF'; // 호버 색상 (Cyan)
                  p.style.opacity = '1';
                } else {
                  // 호버되지 않은 다른 경로들
                  p.style.opacity = '0.5'; // 불투명도 낮춤
                  p.style.fill = '#333333';
                }
              });
            });
            // 호버 이벤트 추가 (mouseleave)
            path.addEventListener('mouseleave', () => {
              if (!paths) return; // 타입 가드
              // 모든 경로의 스타일 초기화
               paths.forEach(p => {
                p.style.fill = '#d3d3d3'; // 기본 색상으로
                p.style.opacity = '1';    // 기본 불투명도로
              });
           });
            // 기존 클릭 이벤트
            const handleClick = () => {
              const clickedId = path.id;
              const matchedProvince = Object.entries(REGION_PATHS).find(
                ([_, regionId]) => regionId === clickedId
              );
              if (matchedProvince) {
                const [provinceName] = matchedProvince;
                router.push(`/provinces/${provinceName}`);
              }
            };
            path.addEventListener('click', handleClick);
            clickHandlers.set(path, handleClick);
          });
        }
      });

}

문제1 : 광주 부분만 다른 곳과 색상이 다르다. (레이어 중첩문제)

다른 곳을 호버할 경우 기존대비 색상이 어두워 지긴 하지만 혼자 더 진하다.

문제점은 전남의 path 위쪽에 광주가 올라가 있다는 점이었다. 혼자 2개의 레이어가 겹쳐서 더 어두워 보이는 것이었다.

**호버되지 않은 다른 경로들
                  p.style.opacity = '0.5'; // 불투명도 낮춤
                  p.style.fill = '#333333';

https://inkscape.org/

 

Inkscape - Draw Freely. | Inkscape

Feb. 5, 2025 For the past few days, 7 Inkscape Members met up in Frankfurt, Germany to collaborate on various projects and get to know each other. We were especially delighted to have two InkStitch maintainers, a major downstream project of Inkscape, join

inkscape.org

복잡한 경로라 해결하기 어렵기 때문에 svg 편집툴로 해당 부분을 잘라냈다.

2개 영역을 시프트로 다중 선택하고 툴 기능인 분할로 해당 부분만 오려냈다.

또한 추가적으로 개체 속성에 들어가서 실제 id를 원본과 일치시켜줘야한다. 오려내면서 새롭게 path가 바뀌고 그 결과 다른 id가 부여되기 때문이다. 실제 id랑 보이는 레이블은 다르기 때문에 확인해서 수정해줘야 한다.

또한 이런 방식으로 바로바로 id를 수정할 수 있어서 더 쉽다.

 

문제1 해결 : 중첩되는 부분이 사라져서 동일한 효과를 받게 되었다.

  useEffect(() => {
    const clickHandlers = new Map();
    fetch('/Map_of_South_Korea-blank.svg')
      .then((res) => res.text())
      .then((svgText) => {
        if (containerRef.current) {
          containerRef.current.innerHTML = svgText;
          const paths = containerRef.current.querySelectorAll('path');
          paths.forEach((path) => { 
              // 기본 스타일 적용
              path.style.fill = ' #FFF9C4';
              path.style.transition = 'fill 0.3s ease, opacity 0.3s ease';
              path.style.cursor = 'pointer';
              path.style.opacity = '1';
              path.style.stroke='#333333';

            // 호버 이벤트 추가
            path.addEventListener('mouseenter', () => {
              // 모든 경로를 순회하며 처리 (클로저의 'paths' 사용)
              paths.forEach(p => {
                if (p === path) {
                  // 현재 호버된 경로
                  p.style.fill = ' #2196F3'; // 호버 색상
                  p.style.opacity = '1';
                } else {
                  // 호버되지 않은 다른 경로들
                  p.style.opacity = '0.5'; // 불투명도 낮춤
                }
              });
            });
            // 호버 이벤트 추가 (mouseleave)
            path.addEventListener('mouseleave', () => {
              if (!paths) return; // 타입 가드
              // 모든 경로의 스타일 초기화
               paths.forEach(p => {
                p.style.fill = '#FFF9C4'; // 기본 색상으로
                p.style.opacity = '1';    // 기본 불투명도로
              });
           });

그라데이션 적용문제

단색 적용시에는 문제가 없지만 그라데이션을 적용하고 거기에 다시 효과를 적용하려고 하니 fill 자체의 한계로 박스를 우선 만들고 거기에 한번에 적용하는 방식을 사용해야 됐다.

 

문제 2 : path는 트랜스폼 효과로 위치를 이동시키기 때문에 실제 위치가 제대로 안나타날 수 있음

  • transform="translate(106.95522,19.462687)

💡해결 방법: 최종 렌더링 좌표 사용 (getBoundingClientRect)

이 문제를 가장 확실하게 해결하는 방법은 transform을 직접 계산하는 대신, 브라우저가 최종적으로 화면에 렌더링하는 요소의 위치 정보를 사용하는 것이다.

  1. path.getBoundingClientRect(): 경로 요소가 화면(Viewport)의 어느 위치에 픽셀 단위로 그려지는지에 대한 정보(left, top, right, bottom, width, height)를 얻는다. 이 정보는 모든 SVG transform과 CSS transform까지 고려된 최종 결과다.
  2. svg.getScreenCTM(): SVG 좌표계에서 화면 좌표계로 변환하는 행렬(Matrix)을 얻는다.
  3. inverse(): 이 행렬의 역행렬을 구하면, 화면 좌표를 다시 SVG 좌표로 변환할 수 있다.
  4. 좌표 변환: getBoundingClientRect()로 얻은 화면 좌표(픽셀)를 역행렬을 이용해 SVG 내부 좌표로 변환한다.
  5. 변환된 SVG 좌표를 사용하여 하이라이트 <rect>의 x, y, width, height를 설정한다.
'use client';

import { useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { MapComponentProps } from '@/types';

const REGION_PATHS: { [key: string]: string } = { /* ... 이전과 동일 ... */
  '경기도': 'gyeonggi', '경상남도': 'gyeongnam', '충청북도': 'chungbuk', '강원도': 'gangwon',
  '충청남도': 'chungnam', '경상북도': 'gyeongbuk', '전라남도': 'jeonnam', '울산광역시': 'ulsan',
  '인천광역시': 'incheon', '광주광역시': 'gwangju', '대전광역시': 'daejeon', '전라북도': 'jeonbuk',
  '부산광역시': 'busan', '서울시': 'seoul', '제주도': 'jeju', '대구광역시': 'daegu'
};

const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
const HIGHLIGHT_RECT_ID = 'bbox-highlight-rect';

const HIGHLIGHT_STYLE = {
  fill: 'rgba(0, 100, 255, 0.2)',
  stroke: 'blue',
  strokeWidth: '1',
  pointerEvents: 'none'
};

export default function MapComponent({ className }: MapComponentProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const svgRef = useRef<SVGSVGElement | null>(null);
  const router = useRouter();
  const eventHandlers = useRef(new Map<SVGPathElement, { [key: string]: EventListener }>());

  useEffect(() => {
    let isMounted = true;
    const currentEventHandlers = new Map<SVGPathElement, { [key: string]: EventListener }>();
    eventHandlers.current = currentEventHandlers;

    fetch('/Map_of_South_Korea-blank.svg')
      .then((res) => res.text())
      .then((svgText) => {
        if (!isMounted || !containerRef.current) return;

        containerRef.current.innerHTML = svgText;
        const svg = containerRef.current.querySelector('svg');
        if (!svg) return;
        svgRef.current = svg;

        const paths = svg.querySelectorAll<SVGPathElement>('path');
        paths.forEach((path) => {
          // 기본 스타일
          path.style.stroke = '#555';
          path.style.strokeWidth = '0.5';
          path.style.cursor = 'pointer';

          // --- Mouse Enter 이벤트 ---
          const handleMouseEnter = () => {
            if (!svgRef.current) return;
            svgRef.current.querySelector(`#${HIGHLIGHT_RECT_ID}`)?.remove();

            // ★★★ getBoundingClientRect() 와 Matrix 사용 ★★★
            const clientRect = path.getBoundingClientRect(); // 1. 화면 기준 BBox (픽셀)
            const svgPoint = svgRef.current.createSVGPoint(); // SVG 좌표 변환용 포인트
            const screenCTM = svgRef.current.getScreenCTM(); // 2. SVG -> 화면 변환 Matrix

            if (!screenCTM) return; // CTM이 null일 경우 처리

            const inverseScreenCTM = screenCTM.inverse(); // 3. 화면 -> SVG 변환 Matrix

            // 4. 화면 좌표(clientRect)를 SVG 좌표로 변환
            svgPoint.x = clientRect.left;
            svgPoint.y = clientRect.top;
            const topLeftSVG = svgPoint.matrixTransform(inverseScreenCTM);

            svgPoint.x = clientRect.right;
            svgPoint.y = clientRect.bottom;
            const bottomRightSVG = svgPoint.matrixTransform(inverseScreenCTM);

            // 5. 변환된 SVG 좌표로 하이라이트 박스 위치/크기 계산
            const rectX = topLeftSVG.x;
            const rectY = topLeftSVG.y;
            const rectWidth = bottomRightSVG.x - topLeftSVG.x;
            const rectHeight = bottomRightSVG.y - topLeftSVG.y;
            // ★★★ --- ★★★

            const highlightRect = document.createElementNS(SVG_NAMESPACE, 'rect');
            highlightRect.setAttribute('id', HIGHLIGHT_RECT_ID);

            // 6. 계산된 SVG 좌표로 속성 설정
            highlightRect.setAttribute('x', `${rectX}`);
            highlightRect.setAttribute('y', `${rectY}`);
            highlightRect.setAttribute('width', `${rectWidth}`);
            highlightRect.setAttribute('height', `${rectHeight}`);

            // 7. 스타일 적용
            Object.entries(HIGHLIGHT_STYLE).forEach(([key, value]) => {
              const styleKey = key.replace(/([A-Z])/g, '-$1').toLowerCase();
              highlightRect.style[styleKey as any] = value;
            });

            // 8. 하이라이트 박스 추가
            svgRef.current.appendChild(highlightRect);
          };

          // --- Mouse Leave 이벤트 ---
          const handleMouseLeave = () => {
            svgRef.current?.querySelector(`#${HIGHLIGHT_RECT_ID}`)?.remove();
          };

          // --- Click 이벤트 (유지) ---
          const handleClick = () => { /* ... 이전과 동일 ... */
            const clickedId = path.id;
            const matchedProvince = Object.entries(REGION_PATHS).find(
                ([_, regionId]) => regionId === clickedId
            );
            if (matchedProvince) {
                const [provinceName] = matchedProvince;
                router.push(`/provinces/${provinceName}`);
            }
          };

          // 이벤트 리스너 등록 및 핸들러 저장
          path.addEventListener('mouseenter', handleMouseEnter);
          path.addEventListener('mouseleave', handleMouseLeave);
          path.addEventListener('click', handleClick);
          currentEventHandlers.set(path, {
            mouseenter: handleMouseEnter,
            mouseleave: handleMouseLeave,
            click: handleClick
          });
        });
      })
      .catch(error => console.error("SVG 로딩 또는 처리 오류:", error));

    // --- Cleanup 함수 ---
    return () => { /* ... 이전과 동일 ... */
      isMounted = false;
      svgRef.current?.querySelector(`#${HIGHLIGHT_RECT_ID}`)?.remove();
      // 이벤트 리스너 제거 등...
      if (eventHandlers.current) {
        eventHandlers.current.forEach((handlers, path) => {
            Object.entries(handlers).forEach(([eventName, handler]) => {
                path.removeEventListener(eventName, handler);
            });
        });
      }
      svgRef.current = null;
      eventHandlers.current.clear();
    };
  }, [router]);

  return (
    <div
      ref={containerRef}
      className={className}
      style={{ width: '100%', height: 'auto', position: 'relative' }}
    />
  );
}

최종적으로는 정확한 위치에 박스를 만들도록 했다.

 

전체 문제의 자세한 해결법은 다음 글에서...