svg의 각 path가 hover할 시 그라데이션 효과와 함께 별 하늘 효과를 적용하려고 했는데 fill의 한계인지 어느 한 패턴이 다른 패턴을 덮어버리는 문제가 발생했다. 만약에 그라데이션을 별 패턴에 적용할 경우 예쁘지 않은 박스 형태로 그라데이션이 적용되버렸다.
...
그렇다고 박스 자체의 적용 크기를 키우면 그라데이션 자체가 제대로 적용이 안되버리니 아예 개발자 도구에서처럼 박스 자체를 좌표로 따서 그 부분에 전체 그라데이션을 적용하고 효과도 주려고 했다.
그런데 웬걸. 실제로 박스를 가져오니 이런 문제가 발생했다.
문제 원인
결론부터 말하자면 path가 가진 transform 속성의 문제였다. 최종적으로 path를 이동시키니 path의 각 좌표를 최상단 하단 좌단 우단을 따도 어긋남이 발생한것.
문제가 뭔지 찾아보려고 svg 전체에 코드를 적용했더니 정확한 좌표로 박스가 생성되어서 path 개별의 transform 문제인 걸 확인했다.
💡 해결 방법: 최종 렌더링 좌표 사용 (getBoundingClientRect)
transform을 직접 계산하는 대신, 브라우저가 최종적으로 화면에 렌더링하는 요소의 위치 정보를 사용하여 해결했다.
- path.getBoundingClientRect(): 경로 요소가 화면(Viewport)의 어느 위치에 픽셀 단위로 그려지는지에 대한 정보(left, top, right, bottom, width, height)를 얻는다. 이 정보는 모든 SVG transform과 CSS transform까지 고려된 최종 결과
- svg.getScreenCTM(): SVG 좌표계에서 화면 좌표계로 변환하는 행렬(Matrix)을 얻는다
- inverse(): 이 행렬의 역행렬을 구하면, 화면 좌표를 다시 SVG 좌표로 변환할 수 있다.
- 좌표 변환: getBoundingClientRect()로 얻은 화면 좌표(픽셀)를 역행렬을 이용해 SVG 내부 좌표로 변환한다.
- 변환된 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" }}
/>
);
}
'nextjs > 웹사이트 만들기' 카테고리의 다른 글
로컬 호스트 프론트와 백 서버 동시 실행 설정 (0) | 2025.04.16 |
---|---|
별의 반짝임 효과 구현하기 (0) | 2025.04.15 |
산정보 보기 웹사이트 만들기(4) - 지도 꾸미기 (0) | 2025.04.14 |
산정보 보기 웹사이트 만들기(3) - 각 산의 상세정보 페이지 만들기 (1) | 2025.04.14 |
산정보 보기 웹사이트 만들기(2) - 지역 이동 링크를 지도 svg에 연결하기 (0) | 2025.04.14 |