별 개수: 50
속도: 2

JavaScript

웹 개발의 필수 언어

동적인 웹 페이지 구현을 위한 핵심 프로그래밍 언어.

Java

객체지향 프로그래밍

안정적이고 확장성 있는 백엔드 개발의 대표 언어.

HTML

웹의 기초

웹 페이지의 구조를 정의하는 마크업 언어.

React

현대적 UI 라이브러리

효율적인 사용자 인터페이스 구축을 위한 JavaScript 라이브러리.

CSS

웹 디자인의 핵심

웹 페이지의 시각적 표현을 담당하는 스타일 언어.

Spring

자바 웹 프레임워크

기업급 애플리케이션 개발을 위한 강력한 프레임워크.

JavaScript

웹 개발의 필수 언어

동적인 웹 페이지 구현을 위한 핵심 프로그래밍 언어.

Java

객체지향 프로그래밍

안정적이고 확장성 있는 백엔드 개발의 대표 언어.

HTML

웹의 기초

웹 페이지의 구조를 정의하는 마크업 언어.

React

현대적 UI 라이브러리

효율적인 사용자 인터페이스 구축을 위한 JavaScript 라이브러리.

CSS

웹 디자인의 핵심

웹 페이지의 시각적 표현을 담당하는 스타일 언어.

Spring

자바 웹 프레임워크

기업급 애플리케이션 개발을 위한 강력한 프레임워크.

JavaScript

웹 개발의 필수 언어

동적인 웹 페이지 구현을 위한 핵심 프로그래밍 언어.

Java

객체지향 프로그래밍

안정적이고 확장성 있는 백엔드 개발의 대표 언어.

HTML

웹의 기초

웹 페이지의 구조를 정의하는 마크업 언어.

React

현대적 UI 라이브러리

효율적인 사용자 인터페이스 구축을 위한 JavaScript 라이브러리.

CSS

웹 디자인의 핵심

웹 페이지의 시각적 표현을 담당하는 스타일 언어.

Spring

자바 웹 프레임워크

기업급 애플리케이션 개발을 위한 강력한 프레임워크.

nextjs/웹사이트 만들기

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

lamarcK 2025. 4. 14. 20:11

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

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

MapComponent.tsx
typescript
클릭하여 코드 펼치기
  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. 문제1 : 광주 부분만 다른 곳과 색상이 다르다. (레이어 중첩문제)

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

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

호버되지 않은 다른 경로들
typescript
클릭하여 코드 펼치기
                  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를 수정할 수 있어서 더 쉽다.

2.  

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

typescript
클릭하여 코드 펼치기
  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';    // 기본 불투명도로
              });
           });
클릭하여 코드 복사

4. 그라데이션 적용문제

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

 

5. 문제 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를 설정한다.
typescript
클릭하여 코드 펼치기
'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' }}
    />
  );
}
클릭하여 코드 복사

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

 

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

이동