Explorar o código

feat(home): 세계지도 핀 상시 라벨(국가명+등락률) + 드래그 이동/확대

- 각 핀에 국가명·등락률 상시 표시(방향색, 배경 헤일로로 가독). 활성 시 종가 추가
- 팬/줌: 기본 1.5배 확대 + 드래그 이동 + 줌 버튼(+/−/초기화)
- 지역 탭 클릭 시 해당 대륙으로 이동·확대(그리드 필터와 연동)
- HTML 툴팁 제거 → SVG 라벨로 대체(팬/줌과 함께 이동)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
KIM-JINO5 hai 2 semanas
pai
achega
46844d57f8
Modificáronse 2 ficheiros con 253 adicións e 96 borrados
  1. 169 96
      app/(main)/_components/WorldMarketMap.tsx
  2. 84 0
      app/(main)/_components/world-map.scss

+ 169 - 96
app/(main)/_components/WorldMarketMap.tsx

@@ -1,7 +1,7 @@
 'use client';
 
 import './world-map.scss';
-import { useEffect, useMemo, useState } from 'react';
+import { useMemo, useRef, useState } from 'react';
 import { geoNaturalEarth1, geoPath } from 'd3-geo';
 import { feature } from 'topojson-client';
 import type { Feature, Geometry } from 'geojson';
@@ -19,19 +19,22 @@ import {
 } from '@/types/worldIndex';
 import TradingViewChart from './TradingViewChart';
 
-// 등거리(plate carrée) 대신 d3-geo geoNaturalEarth1(곡면) 투영.
-// 국가별 topojson feature 를 GeoJSON 으로 디코드해 육지 path 와 핀 좌표를 "동일 projection" 으로 산출 → 오정렬 원천 차단.
+// 등거리 대신 d3-geo geoNaturalEarth1(곡면) 투영. 국가별 topojson feature 를 GeoJSON 으로 디코드해
+// 육지 path 와 핀 좌표를 "동일 projection" 으로 산출. 팬/줌은 <g> transform 으로 처리(드래그 이동·확대).
 // (Wave1 — .claude/plan/global-market-research-pages.md)
 const VW = 1000;
 const VH = 500;
+const MIN_K = 1;
+const MAX_K = 6;
+const DEFAULT_K = 1.5; // 기본 살짝 확대
 
 type GeoFeature = Feature<Geometry>;
 type RegionTab = 'major'|MarketRegion;
+type View = { x: number; y: number; k: number };
 
-// ── 지도 지오메트리: 정적 topojson → GeoJSON feature. 모듈 로드 시 1회 계산(남극 제외) ──
 const COUNTRIES: GeoFeature[] = (() => {
 	const decoded = feature(worldTopo as never, (worldTopo as unknown as { objects: { countries: never } }).objects.countries) as unknown as { features: GeoFeature[] };
-	return decoded.features.filter((f) => String(f.id) !== '010'); // 남극(ATA) 제외 — 하단 여백 방지
+	return decoded.features.filter((f) => String(f.id) !== '010'); // 남극 제외
 })();
 
 const PROJECTION = geoNaturalEarth1().fitSize([VW, VH], { type: 'FeatureCollection', features: COUNTRIES } as never);
@@ -40,6 +43,11 @@ const COUNTRY_PATHS: { id: string; d: string }[] = COUNTRIES
 	.map((f) => ({ id: String(f.id), d: PATH_FN(f as never) ?? '' }))
 	.filter((c) => c.d.length > 0);
 
+const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
+
+// viewBox 중심 기준으로 k 배 확대한 초기 view
+const centeredView = (k: number): View => ({ x: (VW / 2) * (1 - k), y: (VH / 2) * (1 - k), k });
+
 type Props = {
 	rows: WorldIndexRow[];
 };
@@ -63,75 +71,137 @@ export default function WorldMarketMap({ rows }: Props)
 	const [region, setRegion] = useState<RegionTab>('major');
 	const [active, setActive] = useState<string|null>(null);
 	const [chartRow, setChartRow] = useState<WorldIndexRow|null>(null);
+	const [view, setView] = useState<View>(() => centeredView(DEFAULT_K));
+	const [panning, setPanning] = useState(false);
 
-	// 차트 모달 Esc 닫기
-	useEffect(() => {
-		if (!chartRow) {
-			return;
-		}
-		const onKey = (e: KeyboardEvent) => {
-			if (e.key === 'Escape') {
-				setChartRow(null);
-			}
-		};
-		window.addEventListener('keydown', onKey);
-		return () => {
-			window.removeEventListener('keydown', onKey);
-		};
-	}, [chartRow]);
-
-	// 좌표를 아는 국가만 취급
-	const geoRows = useMemo(() => {
-		return rows.filter((r) => EXCHANGE_GEO[r.countryCode]);
-	}, [rows]);
+	const svgRef = useRef<SVGSVGElement>(null);
+	const dragRef = useRef<{ px: number; py: number; vx: number; vy: number; moved: boolean }|null>(null);
+	const suppressClickRef = useRef(false);
 
-	// 핀 — 등락 크기에 따라 반경 가변(5~9), projection 으로 좌표 산출
+	// 좌표를 아는 국가만 취급 + 핀 좌표 산출
 	const pins = useMemo<Pin[]>(() => {
-		return geoRows
+		return rows
 			.map((row) => {
 				const geo = EXCHANGE_GEO[row.countryCode];
+				if (!geo) {
+					return null;
+				}
 				const xy = PROJECTION([geo.lng, geo.lat]);
 				if (!xy) {
 					return null;
 				}
-				const radius = Math.min(9, 5 + Math.abs(row.flucRateBp) / 90);
+				const radius = Math.min(8, 4.5 + Math.abs(row.flucRateBp) / 110);
 				return { row, label: geo.label, x: xy[0], y: xy[1], dir: moveDir(row.flucRateBp), radius };
 			})
 			.filter((c): c is Pin => c !== null);
-	}, [geoRows]);
+	}, [rows]);
 
-	// 지역 탭 — 데이터 있는 탭만 노출(무음 빈 탭 방지). major = 주요국 큐레이션.
+	// 지역 탭 (데이터 있는 탭만)
 	const tabs = useMemo<RegionTab[]>(() => {
 		const counts: Record<RegionTab, number> = { major: 0, asia: 0, europe: 0, america: 0, mideast: 0 };
-		geoRows.forEach((r) => {
-			const geo = EXCHANGE_GEO[r.countryCode];
+		pins.forEach((p) => {
+			const geo = EXCHANGE_GEO[p.row.countryCode];
 			counts[geo.region] += 1;
 			if (geo.major) {
 				counts.major += 1;
 			}
 		});
 		return REGION_ORDER.filter((t) => counts[t] > 0);
-	}, [geoRows]);
+	}, [pins]);
 
-	// 선택 탭이 데이터 없는 탭이면 첫 탭으로 폴백
 	const activeRegion: RegionTab = tabs.includes(region) ? region : (tabs[0] ?? 'major');
 
-	// 그리드 = 선택 지역 필터 + 등락률 내림차순
 	const gridRows = useMemo(() => {
-		return geoRows
+		return pins
+			.map((p) => p.row)
 			.filter((r) => {
 				const geo = EXCHANGE_GEO[r.countryCode];
 				return activeRegion === 'major' ? geo.major === true : geo.region === activeRegion;
 			})
 			.sort((a, b) => b.flucRateBp - a.flucRateBp);
-	}, [geoRows, activeRegion]);
+	}, [pins, activeRegion]);
 
-	// 기준일 = 가장 최근 거래일
 	const asOf = useMemo(() => {
-		return geoRows.reduce<string|null>((max, c) => (max === null || c.tradeDate > max ? c.tradeDate : max), null);
-	}, [geoRows]);
+		return pins.reduce<string|null>((max, c) => (max === null || c.row.tradeDate > max ? c.row.tradeDate : max), null);
+	}, [pins]);
+
+	// 지역 탭 클릭 → 그리드 필터 + 지도를 해당 지역으로 이동/확대
+	function selectRegion(t: RegionTab)
+	{
+		setRegion(t);
+
+		if (t === 'major') {
+			setView(centeredView(DEFAULT_K));
+			return;
+		}
+
+		const regionPins = pins.filter((p) => EXCHANGE_GEO[p.row.countryCode].region === t);
+		if (regionPins.length === 0) {
+			return;
+		}
+
+		const xs = regionPins.map((p) => p.x);
+		const ys = regionPins.map((p) => p.y);
+		const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
+		const cy = (Math.min(...ys) + Math.max(...ys)) / 2;
+		const spreadX = Math.max(Math.max(...xs) - Math.min(...xs), 70);
+		const spreadY = Math.max(Math.max(...ys) - Math.min(...ys), 70);
+		const k = clamp(Math.min(VW / (spreadX * 2.2), VH / (spreadY * 2.2)), 1.6, 4);
+		setView({ x: VW / 2 - cx * k, y: VH / 2 - cy * k, k });
+	}
+
+	function zoomBy(factor: number)
+	{
+		setView((v) => {
+			const k = clamp(v.k * factor, MIN_K, MAX_K);
+			const cx = VW / 2;
+			const cy = VH / 2;
+			return { x: cx - (cx - v.x) * (k / v.k), y: cy - (cy - v.y) * (k / v.k), k };
+		});
+	}
 
-	const activePin = pins.find((c) => c.row.countryCode === active) ?? null;
+	function onPointerDown(e: React.PointerEvent<SVGSVGElement>)
+	{
+		if (e.button !== 0) {
+			return;
+		}
+		dragRef.current = { px: e.clientX, py: e.clientY, vx: view.x, vy: view.y, moved: false };
+		setPanning(true);
+	}
+
+	function onPointerMove(e: React.PointerEvent<SVGSVGElement>)
+	{
+		const d = dragRef.current;
+		if (!d) {
+			return;
+		}
+		const rect = svgRef.current?.getBoundingClientRect();
+		const factor = rect && rect.width > 0 ? VW / rect.width : 1;
+		const dx = (e.clientX - d.px) * factor;
+		const dy = (e.clientY - d.py) * factor;
+		if (Math.abs(e.clientX - d.px) + Math.abs(e.clientY - d.py) > 3) {
+			d.moved = true;
+		}
+		setView((v) => ({ ...v, x: d.vx + dx, y: d.vy + dy }));
+	}
+
+	function endPan()
+	{
+		suppressClickRef.current = dragRef.current?.moved ?? false;
+		dragRef.current = null;
+		setPanning(false);
+	}
+
+	function onPinClick(row: WorldIndexRow)
+	{
+		if (suppressClickRef.current) {
+			suppressClickRef.current = false;
+			return;
+		}
+		if (TV_SYMBOL[row.countryCode]) {
+			setChartRow(row);
+		}
+	}
 
 	return (
 		<section className='world-map' aria-label='세계 주요국 증시지수'>
@@ -147,14 +217,18 @@ export default function WorldMarketMap({ rows }: Props)
 			</div>
 
 			<div className='world-map__body'>
-				{/* 지도 */}
 				<div className='world-map__stage'>
 					<svg
-						className='world-map__svg'
+						ref={svgRef}
+						className={`world-map__svg${panning ? ' is-panning' : ''}`}
 						viewBox={`0 0 ${VW} ${VH}`}
 						role='img'
-						aria-label='세계지도 위 주요국 증시지수 핀'
+						aria-label='세계지도 위 주요국 증시지수 핀 (드래그로 이동, 확대 가능)'
 						preserveAspectRatio='xMidYMid meet'
+						onPointerDown={onPointerDown}
+						onPointerMove={onPointerMove}
+						onPointerUp={endPan}
+						onPointerLeave={endPan}
 					>
 						<defs>
 							<radialGradient id='wm-ocean' cx='50%' cy='42%' r='75%'>
@@ -163,65 +237,64 @@ export default function WorldMarketMap({ rows }: Props)
 							</radialGradient>
 						</defs>
 
-						{/* 대양 배경 */}
+						{/* 대양 (고정 배경 — 팬/줌 영향 없음) */}
 						<rect x='0' y='0' width={VW} height={VH} rx='16' fill='url(#wm-ocean)' />
 
-						{/* 대륙 (국가별 topojson feature) */}
-						<g className='world-map__land'>
-							{COUNTRY_PATHS.map((c) => (
-								<path key={c.id} d={c.d} />
-							))}
-						</g>
+						{/* 팬/줌 그룹 */}
+						<g transform={`translate(${view.x} ${view.y}) scale(${view.k})`}>
+							<g className='world-map__land'>
+								{COUNTRY_PATHS.map((c) => (
+									<path key={c.id} d={c.d} />
+								))}
+							</g>
+
+							<g className='world-map__pins'>
+								{pins.map((pin) => {
+									const isActive = pin.row.countryCode === active;
+									const leftSide = pin.x > VW * 0.72;
+									const lx = leftSide ? -(pin.radius + 4) : pin.radius + 4;
+									const anchor = leftSide ? 'end' : 'start';
+									return (
+										<g
+											key={pin.row.countryCode}
+											className={`world-map__pin world-map__pin--${pin.dir}${isActive ? ' is-active' : ''}`}
+											transform={`translate(${pin.x} ${pin.y})`}
+											role='button'
+											tabIndex={0}
+											aria-label={`${pin.label} ${pin.row.name} ${formatFlucRate(pin.row.flucRateBp)}`}
+											onMouseEnter={() => setActive(pin.row.countryCode)}
+											onMouseLeave={() => setActive((cur) => (cur === pin.row.countryCode ? null : cur))}
+											onFocus={() => setActive(pin.row.countryCode)}
+											onBlur={() => setActive((cur) => (cur === pin.row.countryCode ? null : cur))}
+											onClick={() => onPinClick(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__pins'>
-							{pins.map((pin) => {
-								const isActive = pin.row.countryCode === active;
-								return (
-									<g
-										key={pin.row.countryCode}
-										className={`world-map__pin world-map__pin--${pin.dir}${isActive ? ' is-active' : ''}`}
-										transform={`translate(${pin.x} ${pin.y})`}
-										role='button'
-										tabIndex={0}
-										aria-label={`${pin.label} ${pin.row.name} ${formatFlucRate(pin.row.flucRateBp)}`}
-										onMouseEnter={() => setActive(pin.row.countryCode)}
-										onMouseLeave={() => setActive((cur) => (cur === pin.row.countryCode ? null : cur))}
-										onFocus={() => setActive(pin.row.countryCode)}
-										onBlur={() => setActive((cur) => (cur === pin.row.countryCode ? null : cur))}
-										onClick={() => { if (TV_SYMBOL[pin.row.countryCode]) { 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>
-								);
-							})}
+											{/* 상시 라벨 — 국가명 + 등락률 (핀 옆) */}
+											<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>}
+											</g>
+										</g>
+									);
+								})}
+							</g>
 						</g>
 					</svg>
 
-					{/* 툴팁 — 활성 핀 기준 % 위치 (viewBox 비율) */}
-					{activePin && (
-						<div
-							className='world-map__tip'
-							style={{ left: `${(activePin.x / VW) * 100}%`, top: `${(activePin.y / VH) * 100}%` }}
-							role='status'
-						>
-							<div className='world-map__tip-head'>
-								<span className='world-map__tip-country'>{activePin.label}</span>
-								<span className='world-map__tip-name'>{activePin.row.name}</span>
-							</div>
-							<div className='world-map__tip-close'>{formatIndexClose(activePin.row.close)}</div>
-							<div className={`world-map__tip-change world-map__tip-change--${activePin.dir}`}>
-								{formatFlucRate(activePin.row.flucRateBp)}
-								<span className='world-map__tip-exch'>{activePin.row.exchangeName}</span>
-							</div>
-						</div>
-					)}
+					{/* 줌 컨트롤 */}
+					<div className='world-map__zoom'>
+						<button type='button' className='world-map__zoom-btn' aria-label='확대' onClick={() => zoomBy(1.4)}>+</button>
+						<button type='button' className='world-map__zoom-btn' aria-label='축소' onClick={() => zoomBy(1 / 1.4)}>−</button>
+						<button type='button' className='world-map__zoom-btn' aria-label='초기화' title='초기화' onClick={() => setView(centeredView(DEFAULT_K))}>⤢</button>
+					</div>
 				</div>
 
-				{/* 지수 패널 — 지역 탭 + 데이터 그리드 (지도와 연동) */}
+				{/* 지수 패널 — 지역 탭 + 데이터 그리드 */}
 				<div className='world-map__panel'>
 					<div className='world-map__tabs' role='tablist' aria-label='지역'>
 						{tabs.map((t) => (
@@ -231,7 +304,7 @@ export default function WorldMarketMap({ rows }: Props)
 								role='tab'
 								aria-selected={t === activeRegion}
 								className={`world-map__tab${t === activeRegion ? ' is-active' : ''}`}
-								onClick={() => setRegion(t)}
+								onClick={() => selectRegion(t)}
 							>
 								{REGION_LABELS[t]}
 							</button>

+ 84 - 0
app/(main)/_components/world-map.scss

@@ -93,6 +93,90 @@
 		width: 100%;
 		height: 100%;
 		display: block;
+		cursor: grab;
+		touch-action: none;
+
+		&.is-panning {
+			cursor: grabbing;
+		}
+	}
+
+	// 줌 컨트롤 (지도 우상단)
+	&__zoom {
+		position: absolute;
+		top: var(--sp-3);
+		right: var(--sp-3);
+		display: flex;
+		flex-direction: column;
+		gap: var(--sp-2);
+	}
+
+	&__zoom-btn {
+		width: 28px;
+		height: 28px;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		background: var(--bg-page);
+		border: 1px solid var(--border-default);
+		border-radius: var(--radius);
+		color: var(--text-secondary);
+		font-size: var(--fs-lg);
+		line-height: 1;
+		cursor: pointer;
+
+		&:hover {
+			color: var(--text-primary);
+			border-color: var(--border-strong);
+		}
+	}
+
+	// 상시 핀 라벨 (국가명 + 등락률) — 지도 위 가독 위해 배경색 헤일로(paint-order stroke)
+	&__label {
+		pointer-events: none;
+	}
+
+	&__label-name {
+		font-size: 12px;
+		font-weight: 700;
+		fill: var(--text-primary);
+		stroke: var(--bg-elevated);
+		stroke-width: 3px;
+		paint-order: stroke;
+		vector-effect: non-scaling-stroke;
+	}
+
+	&__label-rate {
+		font-size: 12px;
+		font-weight: 700;
+		font-variant-numeric: tabular-nums;
+		stroke: var(--bg-elevated);
+		stroke-width: 3px;
+		paint-order: stroke;
+		vector-effect: non-scaling-stroke;
+
+		&--up {
+			fill: var(--price-up);
+		}
+
+		&--down {
+			fill: var(--price-down);
+		}
+
+		&--flat {
+			fill: var(--price-flat);
+		}
+	}
+
+	&__label-close {
+		font-size: 10px;
+		font-weight: 600;
+		font-variant-numeric: tabular-nums;
+		fill: var(--text-secondary);
+		stroke: var(--bg-elevated);
+		stroke-width: 3px;
+		paint-order: stroke;
+		vector-effect: non-scaling-stroke;
 	}
 
 	// 대륙 (국가별 topojson feature path)