nextjs/웹사이트 만들기

산정보 보기 웹사이트 만들기(5) - path의 transform 효과로 박스가 제대로 나타나지 않는 문제 해결

lamarcK 2025. 4. 15. 06:51

svg의 각 path가 hover할 시 그라데이션 효과와 함께 별 하늘 효과를 적용하려고 했는데 fill의 한계인지 어느 한 패턴이 다른 패턴을 덮어버리는 문제가 발생했다. 만약에 그라데이션을 별 패턴에 적용할 경우 예쁘지 않은 박스 형태로 그라데이션이 적용되버렸다.

...

그렇다고 박스 자체의 적용 크기를 키우면 그라데이션 자체가 제대로 적용이 안되버리니 아예 개발자 도구에서처럼 박스 자체를 좌표로 따서 그 부분에 전체 그라데이션을 적용하고 효과도 주려고 했다.

 

그런데 웬걸. 실제로 박스를 가져오니 이런 문제가 발생했다.

문제 원인

결론부터 말하자면 path가 가진 transform 속성의 문제였다. 최종적으로 path를 이동시키니 path의 각 좌표를 최상단 하단 좌단 우단을 따도 어긋남이 발생한것.

 

문제가 뭔지 찾아보려고 svg 전체에 코드를 적용했더니 정확한 좌표로 박스가 생성되어서 path 개별의 transform 문제인 걸 확인했다.

💡 해결 방법: 최종 렌더링 좌표 사용 (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를 설정한다.
**// ★★★ 좌표 계산 및 박스 생성 시작 ★★★

// 1. path의 화면 기준 BBox 정보 가져오기 (픽셀 단위)
const clientRect = path.getBoundingClientRect();

// 2. SVG 요소 및 좌표 변환용 포인트, Matrix 준비
const svg = svgRef.current; // 편의상 변수 할당
if (!svg) return; // svg 요소 없으면 중단

const svgPoint = svg.createSVGPoint(); // SVG 좌표계의 점 생성
const screenCTM = svg.getScreenCTM(); // SVG -> 화면 변환 Matrix

if (!screenCTM) return; // CTM 없으면 중단

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

// 3. 화면 좌표(clientRect의 좌상단, 우하단)를 SVG 좌표로 변환
svgPoint.x = clientRect.left;   // 화면 좌측 X
svgPoint.y = clientRect.top;    // 화면 상단 Y
const topLeftSVG = svgPoint.matrixTransform(inverseScreenCTM); // 변환된 SVG 좌상단 좌표

svgPoint.x = clientRect.right;  // 화면 우측 X
svgPoint.y = clientRect.bottom; // 화면 하단 Y
const bottomRightSVG = svgPoint.matrixTransform(inverseScreenCTM); // 변환된 SVG 우하단 좌표

// 4. 변환된 SVG 좌표를 이용해 하이라이트 박스의 최종 위치/크기 계산
const rectX = topLeftSVG.x;
const rectY = topLeftSVG.y;
const rectWidth = bottomRightSVG.x - topLeftSVG.x;
const rectHeight = bottomRightSVG.y - topLeftSVG.y;

// 5. 하이라이트 박스(<rect>) 요소 생성
const highlightRect = document.createElementNS(SVG_NAMESPACE, 'rect');
highlightRect.setAttribute('id', HIGHLIGHT_RECT_ID); // 식별용 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. 생성된 하이라이트 박스를 SVG에 추가 (화면 상 마지막 요소로)
svg.appendChild(highlightRect);

// ★★★ 좌표 계산 및 박스 생성 끝 ★★★

 

**// --- ▼▼▼ 여기가 박스를 만드는 핵심 부분 ▼▼▼ ---

// 1. <rect> 요소 생성 (SVG 네임스페이스 사용)
const highlightRect = document.createElementNS(SVG_NAMESPACE, 'rect');

// 2. 식별용 ID 설정
highlightRect.setAttribute('id', HIGHLIGHT_RECT_ID);

// 3. 계산된 좌표와 크기로 박스의 위치/크기 속성 설정
highlightRect.setAttribute('x', `${rectX}`);
highlightRect.setAttribute('y', `${rectY}`);
highlightRect.setAttribute('width', `${rectWidth}`);
highlightRect.setAttribute('height', `${rectHeight}`);

// 4. 미리 정의된 스타일 적용 (색상, 투명도, 테두리 등)
Object.entries(HIGHLIGHT_STYLE).forEach(([key, value]) => {
  const styleKey = key.replace(/([A-Z])/g, '-$1').toLowerCase();
  highlightRect.style[styleKey as any] = value;
});

// 5. 생성 및 설정 완료된 박스를 실제 SVG DOM에 추가 (화면에 보이게 함)
svgRef.current.appendChild(highlightRect);

// --- ▲▲▲ 여기가 박스를 만드는 핵심 부분 ▲▲▲ ---

지금부터 바꿀 부분이 원래 저곳에 적용되는 스타일이다.

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

스타일 적용의 복잡성

결국 구현으로 해결해야하는 부분이 레이어 2개를 겹쳐서 2개의 효과를 내야하는 부분이다.

fill 자체의 한계로 그라데이션이 격자로 보이니 각각 효과를 준 레이어 2개를 합치는 방식을 택했다.

동적으로 크기가 변하는 박스를 하나 만들고 거기에 그라데이션 효과를 준다음

path 부분에 별 반짝임 효과를 부여했다.

추가적으로 동적으로 크기가 변하는 박스는 clipPath 기능을 추가로 적용해서 path 모양대로 잘랐다.

 

결과적으로 box 만들기, 그라데이션 효과주기, path 모양으로 clip하기 + path에 별 효과 주기로 코드가 좀 복잡해졌다.

사실 단색으로 별 효과만 주면 간단했을 것이다.

원본 파일의 polyline을 살리기 위해 기존 path부분과 polyline을 동시에 인식할 수있는 코드로 변경하였다.

쿼리셀렉터 부분.

"use client";

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

// ... (REGION_PATHS 정의) ...
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 = "background-gradient-rect"; // 배경 박스 ID 변경
const DEFAULT_FILL_COLOR = "#FFF9C4"; // 기본 채우기 색상

// 별 애니메이션 스타일 (투명 배경에 흰색 별)
const STAR_STYLE = { fill: "white", opacity: 0.9 };
const STAR_ANIMATE_VALUES = "0.9;0.4;0.1;0.3;0.7;0.9"; // 반짝임 값

type BBoxData = { x: number; y: number; width: number; height: number };

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

  useEffect(() => {
    let isMounted = true;
    precalculatedBBoxes.current.clear();
    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;

        // --- ★★★ <defs> 정의: 그라데이션과 별 패턴 분리 ★★★ ---
        const defs = document.createElementNS(SVG_NAMESPACE, "defs");

        // 1. 늘어나는 그라데이션 정의 (배경 박스용)
        const gradient = document.createElementNS(
          SVG_NAMESPACE,
          "linearGradient"
        );
        gradient.setAttribute("id", "night-sky-gradient"); // 그라데이션 ID
        gradient.setAttribute("gradientUnits", "objectBoundingBox"); // ★ 중요: 박스 크기에 맞게 늘어남
        gradient.setAttribute("x1", "0");
        gradient.setAttribute("y1", "0");
        gradient.setAttribute("x2", "1");
        gradient.setAttribute("y2", "1");
        const stop1 = document.createElementNS(SVG_NAMESPACE, "stop");
        stop1.setAttribute("offset", "0%");
        stop1.setAttribute("style", "stop-color:#001433; stop-opacity:1");
        const stop2 = document.createElementNS(SVG_NAMESPACE, "stop");
        stop2.setAttribute("offset", "100%");
        stop2.setAttribute("style", "stop-color:#0047AB; stop-opacity:1");
        gradient.appendChild(stop1);
        gradient.appendChild(stop2);
        defs.appendChild(gradient);

        // 2. 별만 있는 패턴 정의 (호버된 경로용)
        const starPattern = document.createElementNS(SVG_NAMESPACE, "pattern");
        starPattern.setAttribute("id", "stars-only-pattern"); // 별 패턴 ID
        starPattern.setAttribute("patternUnits", "userSpaceOnUse"); // ★ 중요: 별 크기 고정
        starPattern.setAttribute("width", "200"); // 패턴 타일 크기
        starPattern.setAttribute("height", "200");
        // 패턴 배경은 투명하게 (아무것도 안 그림)

        // 별 생성 로직 (애니메이션 포함)
        const stars = [
          /* ... 이전과 동일한 별 좌표 ... */ { cx: "18", cy: "32", r: "1.1" },
          { cx: "43", cy: "85", r: "0.9" },
          { cx: "72", cy: "28", r: "1.3" },
          { cx: "117", cy: "55", r: "1.0" },
          { cx: "159", cy: "23", r: "1.2" },
          { cx: "136", cy: "132", r: "0.8" },
          { cx: "32", cy: "152", r: "1.4" },
          { cx: "64", cy: "179", r: "1.1" },
          { cx: "185", cy: "117", r: "0.9" },
          { cx: "96", cy: "67", r: "1.3" },
        ];
        stars.forEach((star) => {
          const starElement = document.createElementNS(SVG_NAMESPACE, "circle");
          starElement.setAttribute("cx", star.cx);
          starElement.setAttribute("cy", star.cy);
          starElement.setAttribute("r", star.r);
          starElement.setAttribute("fill", STAR_STYLE.fill);
          starElement.style.opacity = `${STAR_STYLE.opacity}`; // 초기 투명도

          const animate = document.createElementNS(SVG_NAMESPACE, "animate");
          animate.setAttribute("attributeName", "opacity");
          animate.setAttribute("values", STAR_ANIMATE_VALUES);
          animate.setAttribute("dur", `${1 + Math.random() * 1.5}s`);
          animate.setAttribute("repeatCount", "indefinite");
          starElement.appendChild(animate);
          starPattern.appendChild(starElement); // ★ 패턴에 별만 추가
        });
        defs.appendChild(starPattern); // 별 패턴 defs에 추가

        // 3. 클리핑 경로(<clipPath>) 정의 (배경 박스 잘라내기용)
        const clipPath = document.createElementNS(SVG_NAMESPACE, "clipPath");
        clipPath.setAttribute("id", "hover-clip"); // 고유 ID 설정 (CSS에서 참조)

        // 3-1. 클리핑 경로 내에서 사용할 <use> 요소 생성
        const clipUse = document.createElementNS(SVG_NAMESPACE, "use");
        clipUse.setAttribute("id", "clip-path-use-element"); // 고유 ID 설정 (JS에서 참조)
        // href 속성은 나중에 mouseenter 시 동적으로 설정될 것이므로 여기서는 비워두거나 기본값 설정
        clipUse.setAttribute("href", ""); // 초기값 설정

        // 3-2. 생성된 <use> 요소를 <clipPath> 요소의 자식으로 추가
        clipPath.appendChild(clipUse);

        // 3-3. 완성된 <clipPath>를 위에서 만든 메인 <defs> 요소에 추가
        defs.appendChild(clipPath);

        svg.insertBefore(defs, svg.firstChild); // defs 삽입
        // --- <defs> 정의 끝 ---

        // --- BBox 미리 계산 (이전과 동일) ---
        const screenCTM = svg.getScreenCTM();
        if (!screenCTM) return;
        const inverseScreenCTM = screenCTM.inverse();
        const svgPoint = svg.createSVGPoint();
        const paths = svg.querySelectorAll<SVGPathElement>("polyline, path");

        paths.forEach((path) => {
          // 각 path에 대한 정확한 BBox 계산 및 저장
          const clientRect = path.getBoundingClientRect();
          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);
          const bboxData: BBoxData = {
            /* ... x, y, width, height 계산 ... */ x: topLeftSVG.x,
            y: topLeftSVG.y,
            width: bottomRightSVG.x - topLeftSVG.x,
            height: bottomRightSVG.y - topLeftSVG.y,
          };
          precalculatedBBoxes.current.set(path, bboxData);
        }); // --- BBox 계산 끝 ---

        // --- 각 Path에 이벤트 리스너 설정 ---
        paths.forEach((path) => {
          // 기본 스타일
          path.style.fill = DEFAULT_FILL_COLOR; // 기본 채우기 색상
          path.style.stroke = "#555";
          path.style.strokeWidth = "0.5";
          path.style.transition = "opacity 0.3s ease"; // fill 전환은 제거하거나 조정
          path.style.cursor = "pointer";
          path.style.opacity = "1";

          // --- Mouse Enter 핸들러 (clipPath 적용 버전) ---
          const handleMouseEnter = () => {
            const currentSvg = svgRef.current;
            // 'paths' 컬렉션이 이 스코프에서 필요하므로 다시 가져오거나 상위 스코프에서 접근 가능해야 합니다.
            // 여기서는 핸들러 내에서 다시 가져오는 것으로 가정합니다. (필요시 조정)
            const allPaths =
              currentSvg?.querySelectorAll<SVGGraphicsElement>("path, polyline");
            if (!currentSvg || !allPaths) return; // svg나 경로 없으면 중단

            // 이전 배경 박스 제거
            currentSvg.querySelector(`#${HIGHLIGHT_RECT_ID}`)?.remove();

            // 미리 계산된 BBox 데이터 가져오기
            const bboxData = precalculatedBBoxes.current.get(path);

            if (bboxData) {
              // ★★★ 1. <defs> 안의 <use> 요소 href 업데이트 ★★★
              // <clipPath> 내부의 <use> 요소를 ID로 찾습니다.
              const clipUseElement = currentSvg.querySelector<SVGUseElement>(
                "#clip-path-use-element"
              );
              if (clipUseElement) {
                // 찾은 <use> 요소의 href 속성을 현재 호버된 path의 ID로 설정합니다.
                // (예: path.id가 'gyeonggi'면 '#gyeonggi'로 설정됨)
                clipUseElement.setAttribute("href", "#" + path.id);
              } else {
                // <use> 요소를 찾지 못하면 에러 로그 출력 (문제가 없는지 확인용)
                console.error(
                  "<use id='clip-path-use-element'> 요소를 찾을 수 없습니다!"
                );
                // return; // 에러 시 중단할 수도 있음
              }
              // ★★★ --- ★★★

              // 2. 배경 박스(<rect>) 생성 및 그라데이션 적용 (이전과 동일)
              const bgRect = document.createElementNS(SVG_NAMESPACE, "rect");
              bgRect.setAttribute("id", HIGHLIGHT_RECT_ID);
              bgRect.setAttribute("x", `${bboxData.x}`);
              bgRect.setAttribute("y", `${bboxData.y}`);
              bgRect.setAttribute("width", `${bboxData.width}`);
              bgRect.setAttribute("height", `${bboxData.height}`);
              bgRect.setAttribute("fill", "url(#night-sky-gradient)");
              bgRect.style.pointerEvents = "none";

              // ★★★ 3. 생성된 배경 박스에 clip-path 속성 적용 ★★★
              // 위에서 href를 업데이트한 <clipPath id="hover-clip">을 참조하도록 설정합니다.
              bgRect.setAttribute("clip-path", "url(#hover-clip)");
              // ★★★ --- ★★★

              // 4. 배경 박스를 SVG의 맨 앞에 삽입 (시각적으로 뒤)
              currentSvg.insertBefore(bgRect, currentSvg.firstChild);

              // 5. 호버된 경로(<path>)에 별 패턴 적용 (이전과 동일)
              path.style.fill = "url(#stars-only-pattern)";
              path.style.opacity = "1";

              // 6. 다른 경로들 흐리게 (이전과 동일)
              allPaths.forEach((p) => {
                // 이 핸들러 스코프 내에서 'allPaths' 사용
                if (p !== path) {
                  p.style.opacity = "0.3";
                }
              });
            }
          }; // --- handleMouseEnter 정의 끝 ---

          // --- Mouse Leave 핸들러 ---
          const handleMouseLeave = () => {
            // 1. 배경 박스 제거
            svgRef.current?.querySelector(`#${HIGHLIGHT_RECT_ID}`)?.remove();
            // 2. 모든 경로 스타일 초기화
            paths.forEach((p) => {
              p.style.fill = DEFAULT_FILL_COLOR;
              p.style.opacity = "1";
            });
          };

          // --- Click 이벤트 (오류 수정된 버전) ---
          const handleClick = () => {
            // 1. 클릭된 path 요소의 id 속성 가져오기 (예: 'seoul', 'gyeonggi')
            const clickedId = path.id;

            // 2. REGION_PATHS 객체를 [Key, Value] 배열로 변환
            //    (예: [['경기도', 'gyeonggi'], ['서울시', 'seoul'], ...])
            const regionEntries = Object.entries(REGION_PATHS);

            // 3. 배열에서 Value(regionId)가 clickedId와 일치하는 첫 번째 항목 찾기
            const matchedProvince = regionEntries.find(
              // 각 항목([provinceName, regionId])에 대해 실행되는 함수
              ([provinceName, regionId]) => regionId === clickedId
              //   ^ 각 배열 요소를 [지역명, ID]로 분해   ^ Value(ID)와 클릭된 ID가 같은지 비교
            );

            // 4. 일치하는 항목을 찾았다면 (matchedProvince가 undefined가 아니라면)
            if (matchedProvince) {
              // 5. 찾은 항목([지역명, ID])에서 지역명(Key)만 추출
              const [provinceName] = matchedProvince; // 배열 구조 분해 할당 사용

              // 6. Next.js 라우터를 사용하여 해당 지역 페이지로 이동
              //    (예: /provinces/서울시)
              router.push(`/provinces/${provinceName}`);
            } else {
              // 일치하는 항목이 없을 경우 (디버깅용 로그)
              console.warn(
                `ID '${clickedId}'에 해당하는 지역을 찾을 수 없습니다.`
              );
            }
          };

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

    // --- Cleanup 함수 ---
    return () => {
      /* ... 리스너 제거 및 Map 클리어 ... */
      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;
      precalculatedBBoxes.current.clear();
      eventHandlers.current.clear();
    };
  }, [router]);

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