Sfoglia il codice sorgente

feat(home): 세계지도 map-pin(막대+머리) 스타일 + 마우스 휠 확대/축소

- 핀을 지점에서 위로 뻗은 막대 + 머리(원) 형태로 변경(첨부 아이콘 스타일) — 머리가 지점 위로
  떠서 라벨/머리 겹침 완화. 방향색 머리 + 흰 테두리 + 그림자로 부양감
- 마우스 휠로 커서 위치 기준 확대/축소(네이티브 non-passive 리스너, preventDefault)
- 라벨(국가명+등락률)을 머리 옆으로 이동

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
KIM-JINO5 2 settimane fa
parent
commit
bd48762e8a

+ 37 - 10
app/(main)/_components/WorldMarketMap.tsx

@@ -1,7 +1,7 @@
 'use client';
 'use client';
 
 
 import './world-map.scss';
 import './world-map.scss';
-import { useMemo, useRef, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
 import { geoNaturalEarth1, geoPath } from 'd3-geo';
 import { geoNaturalEarth1, geoPath } from 'd3-geo';
 import { feature } from 'topojson-client';
 import { feature } from 'topojson-client';
 import type { Feature, Geometry } from 'geojson';
 import type { Feature, Geometry } from 'geojson';
@@ -27,6 +27,7 @@ const VH = 500;
 const MIN_K = 1;
 const MIN_K = 1;
 const MAX_K = 6;
 const MAX_K = 6;
 const DEFAULT_K = 1.5; // 기본 살짝 확대
 const DEFAULT_K = 1.5; // 기본 살짝 확대
+const PIN_H = 15; // 핀 막대 높이 — 머리를 지점 위로 띄워 겹침 완화(map-pin)
 
 
 type GeoFeature = Feature<Geometry>;
 type GeoFeature = Feature<Geometry>;
 type RegionTab = 'major'|MarketRegion;
 type RegionTab = 'major'|MarketRegion;
@@ -78,6 +79,32 @@ export default function WorldMarketMap({ rows }: Props)
 	const dragRef = useRef<{ px: number; py: number; vx: number; vy: number; moved: boolean }|null>(null);
 	const dragRef = useRef<{ px: number; py: number; vx: number; vy: number; moved: boolean }|null>(null);
 	const suppressClickRef = useRef(false);
 	const suppressClickRef = useRef(false);
 
 
+	// 마우스 휠 확대/축소 (커서 위치 기준). React onWheel 은 passive 라 네이티브 리스너로 등록(preventDefault 필요).
+	useEffect(() => {
+		const svg = svgRef.current;
+		if (!svg) {
+			return;
+		}
+		const onWheel = (e: WheelEvent) => {
+			e.preventDefault();
+			const rect = svg.getBoundingClientRect();
+			if (rect.width === 0) {
+				return;
+			}
+			const cx = (e.clientX - rect.left) * (VW / rect.width);
+			const cy = (e.clientY - rect.top) * (VH / rect.height);
+			const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
+			setView((v) => {
+				const k = clamp(v.k * factor, MIN_K, MAX_K);
+				return { x: cx - (cx - v.x) * (k / v.k), y: cy - (cy - v.y) * (k / v.k), k };
+			});
+		};
+		svg.addEventListener('wheel', onWheel, { passive: false });
+		return () => {
+			svg.removeEventListener('wheel', onWheel);
+		};
+	}, []);
+
 	// 좌표를 아는 국가만 취급 + 핀 좌표 산출
 	// 좌표를 아는 국가만 취급 + 핀 좌표 산출
 	const pins = useMemo<Pin[]>(() => {
 	const pins = useMemo<Pin[]>(() => {
 		return rows
 		return rows
@@ -269,15 +296,15 @@ export default function WorldMarketMap({ rows }: Props)
 											onClick={() => onPinClick(pin.row)}
 											onClick={() => onPinClick(pin.row)}
 											onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && TV_SYMBOL[pin.row.countryCode]) { e.preventDefault(); setChartRow(pin.row); } }}
 											onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && TV_SYMBOL[pin.row.countryCode]) { e.preventDefault(); setChartRow(pin.row); } }}
 										>
 										>
-											{pin.dir !== 'flat' && <circle className='world-map__pulse' r={pin.radius} />}
-											<circle className='world-map__pin-dot' r={pin.radius} />
-											<circle className='world-map__pin-core' r={pin.radius / 2.6} />
-
-											{/* 상시 라벨 — 국가명 + 등락률 ( 옆) */}
-											<g className='world-map__label' transform={`translate(${lx} 0)`} textAnchor={anchor}>
-												<text className='world-map__label-name' y='-1.5'>{pin.label}</text>
-												<text className={`world-map__label-rate world-map__label-rate--${pin.dir}`} y='9'>{formatFlucRate(pin.row.flucRateBp)}</text>
-												{isActive && <text className='world-map__label-close' y='19'>{formatIndexClose(pin.row.close)}</text>}
+											{/* map-pin: 지점에서 위로 뻗은 막대 + 머리(원) */}
+											<line className='world-map__pin-stem' x1='0' y1='0' x2='0' y2={-PIN_H} />
+											<circle className='world-map__pin-head' cx='0' cy={-PIN_H} r={pin.radius} />
+
+											{/* 상시 라벨 — 국가명 + 등락률 (머리 옆) */}
+											<g className='world-map__label' transform={`translate(${lx} ${-PIN_H})`} textAnchor={anchor}>
+												<text className='world-map__label-name' y='-2'>{pin.label}</text>
+												<text className={`world-map__label-rate world-map__label-rate--${pin.dir}`} y='8.5'>{formatFlucRate(pin.row.flucRateBp)}</text>
+												{isActive && <text className='world-map__label-close' y='18'>{formatIndexClose(pin.row.close)}</text>}
 											</g>
 											</g>
 										</g>
 										</g>
 									);
 									);

+ 16 - 19
app/(main)/_components/world-map.scss

@@ -206,30 +206,27 @@
 		}
 		}
 	}
 	}
 
 
-	&__pin-dot {
-		fill: currentColor;
-		filter: drop-shadow(0 0 3px currentColor);
-		transition: r 0.15s ease;
-	}
-
-	&__pin-core {
-		fill: rgba(255, 255, 255, 0.92);
-		pointer-events: none;
+	// map-pin 막대 (지점 → 머리)
+	&__pin-stem {
+		stroke: currentColor;
+		stroke-width: 2;
+		stroke-linecap: round;
+		opacity: 0.65;
+		vector-effect: non-scaling-stroke;
 	}
 	}
 
 
-	// 확산 링 (상승/하락 핀)
-	&__pulse {
+	// map-pin 머리 (방향색 + 흰 테두리 + 그림자로 부양감)
+	&__pin-head {
 		fill: currentColor;
 		fill: currentColor;
-		opacity: 0.5;
-		transform-box: fill-box;
-		transform-origin: center;
-		animation: wm-pulse 2.4s ease-out infinite;
-		pointer-events: none;
-	}
-
-	&__pin.is-active &__pin-dot {
 		stroke: rgba(255, 255, 255, 0.9);
 		stroke: rgba(255, 255, 255, 0.9);
 		stroke-width: 1.5;
 		stroke-width: 1.5;
+		vector-effect: non-scaling-stroke;
+		filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.45));
+		transition: r 0.15s ease;
+	}
+
+	&__pin.is-active &__pin-head {
+		stroke-width: 2.5;
 	}
 	}
 
 
 	// 툴팁
 	// 툴팁