본격적으로 지도를 꾸며서 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';
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을 직접 계산하는 대신, 브라우저가 최종적으로 화면에 렌더링하는 요소의 위치 정보를 사용하는 것이다.
- 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 |