본격적으로 지도를 꾸며서 hover가 의미있도록 하려고 한다.
fetch를 통해 불러온 것이기 때문에 직접 onclick 이벤트를 부여하면서 hover 효과도 같이 부여한다.
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개의 레이어가 겹쳐서 더 어두워 보이는 것이었다.
p.style.opacity = '0.5'; // 불투명도 낮춤
p.style.fill = '#333333';
클릭하여 코드 복사
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 해결 : 중첩되는 부분이 사라져서 동일한 효과를 받게 되었다.

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을 직접 계산하는 대신, 브라우저가 최종적으로 화면에 렌더링하는 요소의 위치 정보를 사용하는 것이다.
- 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를 설정한다.
'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' }}
/>
);
}
클릭하여 코드 복사
최종적으로는 정확한 위치에 박스를 만들도록 했다.

전체 문제의 자세한 해결법은 다음 글에서...
'nextjs > 웹사이트 만들기' 카테고리의 다른 글
별의 반짝임 효과 구현하기 (0) | 2025.04.15 |
---|---|
산정보 보기 웹사이트 만들기(5) - path의 transform 효과로 박스가 제대로 나타나지 않는 문제 해결 (1) | 2025.04.15 |
산정보 보기 웹사이트 만들기(3) - 각 산의 상세정보 페이지 만들기 (1) | 2025.04.14 |
산정보 보기 웹사이트 만들기(2) - 지역 이동 링크를 지도 svg에 연결하기 (0) | 2025.04.14 |
산정보 보기 웹사이트 만들기(1) - sql을 사용해서 데이터 가져오기 (0) | 2025.04.14 |