Răsfoiți Sursa

feat(home): 세계지수 클릭 시 TradingView 차트 드릴다운 모달

- TradingViewChart 컴포넌트(고급차트 임베드, useTheme isDark 연동)
- 데이터 그리드 차트 버튼 / 지도 핀 클릭 → 해당 지수 TradingView 모달
- TV_SYMBOL 국가→대표지수 심볼 best-effort 매핑(미매핑국은 지도 하이라이트 폴백)
- 모달 Esc/백드롭 닫기 · "전일 마감(자체) + 실시간(TradingView)" 병행

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
KIM-JINO5 2 săptămâni în urmă
părinte
comite
f59e413f6b

+ 58 - 0
app/(main)/_components/TradingViewChart.tsx

@@ -0,0 +1,58 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+import useTheme from '@/hooks/useTheme';
+
+// TradingView 고급차트 임베드 — 세계지수 드릴다운(실시간 보완). 시세 표시 책임은 TradingView 측.
+// symbol 은 types/worldIndex.ts TV_SYMBOL 매핑값(예: "KRX:KOSPI", "TVC:NI225").
+type Props = {
+	symbol: string;
+};
+
+export default function TradingViewChart({ symbol }: Props)
+{
+	const containerRef = useRef<HTMLDivElement>(null);
+	const { isDark } = useTheme();
+
+	useEffect(() => {
+		const container = containerRef.current;
+		if (!container) {
+			return;
+		}
+
+		container.innerHTML = '';
+
+		const widget = document.createElement('div');
+		widget.className = 'tradingview-widget-container__widget';
+		widget.style.height = '100%';
+		widget.style.width = '100%';
+		container.appendChild(widget);
+
+		const script = document.createElement('script');
+		script.type = 'text/javascript';
+		script.src = 'https://s3.tradingview.com/external-embedding/embed-widget-advanced-chart.js';
+		script.async = true;
+		script.innerHTML = JSON.stringify({
+			symbol,
+			autosize: true,
+			interval: 'D',
+			timezone: 'Asia/Seoul',
+			theme: isDark ? 'dark' : 'light',
+			style: '1',
+			locale: 'kr',
+			hide_side_toolbar: true,
+			allow_symbol_change: false,
+			calendar: false,
+			support_host: 'https://www.tradingview.com'
+		});
+		container.appendChild(script);
+
+		return () => {
+			container.innerHTML = '';
+		};
+	}, [symbol, isDark]);
+
+	return (
+		<div className='tradingview-widget-container' ref={containerRef} style={{ height: '100%', width: '100%' }} />
+	);
+}

+ 48 - 5
app/(main)/_components/WorldMarketMap.tsx

@@ -1,7 +1,7 @@
 'use client';
 
 import './world-map.scss';
-import { useMemo, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
 import { geoNaturalEarth1, geoPath } from 'd3-geo';
 import { feature } from 'topojson-client';
 import type { Feature, Geometry } from 'geojson';
@@ -14,8 +14,10 @@ import {
 	formatFlucRate,
 	formatIndexClose,
 	type WorldIndexRow,
-	type MarketRegion
+	type MarketRegion,
+	TV_SYMBOL
 } from '@/types/worldIndex';
+import TradingViewChart from './TradingViewChart';
 
 // 등거리(plate carrée) 대신 d3-geo geoNaturalEarth1(곡면) 투영.
 // 국가별 topojson feature 를 GeoJSON 으로 디코드해 육지 path 와 핀 좌표를 "동일 projection" 으로 산출 → 오정렬 원천 차단.
@@ -60,6 +62,23 @@ 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);
+
+	// 차트 모달 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(() => {
@@ -170,6 +189,7 @@ export default function WorldMarketMap({ rows }: Props)
 										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); } }}
 									>
 										{pin.dir !== 'flat' && <circle className='world-map__pulse' r={pin.radius} />}
 										<circle className='world-map__pin-dot' r={pin.radius} />
@@ -253,9 +273,9 @@ export default function WorldMarketMap({ rows }: Props)
 												<button
 													type='button'
 													className='world-map__chart-btn'
-													title={`${geo.label} 위치 보기`}
-													aria-label={`${geo.label} ${row.name} 지도에서 보기`}
-													onClick={() => setActive(row.countryCode)}
+													title={TV_SYMBOL[row.countryCode] ? `${row.name} 차트 보기` : `${geo.label} 위치 보기`}
+													aria-label={TV_SYMBOL[row.countryCode] ? `${geo.label} ${row.name} 차트 보기` : `${geo.label} ${row.name} 지도에서 보기`}
+													onClick={() => { if (TV_SYMBOL[row.countryCode]) { setChartRow(row); } else { setActive(row.countryCode); } }}
 												>
 													<svg viewBox='0 0 24 24' width='16' height='16' aria-hidden='true'>
 														<polyline points='3,17 9,11 13,15 21,6' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round' strokeLinejoin='round' />
@@ -271,6 +291,29 @@ export default function WorldMarketMap({ rows }: Props)
 					</div>
 				</div>
 			</div>
+
+			{chartRow && TV_SYMBOL[chartRow.countryCode] && (
+				<div
+					className='world-map__modal'
+					role='dialog'
+					aria-modal='true'
+					aria-label={`${EXCHANGE_GEO[chartRow.countryCode]?.label ?? ''} ${chartRow.name} 차트`}
+					onClick={() => setChartRow(null)}
+				>
+					<div className='world-map__modal-panel' onClick={(e) => e.stopPropagation()}>
+						<div className='world-map__modal-head'>
+							<span className='world-map__modal-title'>
+								{EXCHANGE_GEO[chartRow.countryCode]?.label} · {chartRow.name}
+							</span>
+							<button type='button' className='world-map__modal-close' aria-label='닫기' onClick={() => setChartRow(null)}>×</button>
+						</div>
+						<div className='world-map__modal-body'>
+							<TradingViewChart symbol={TV_SYMBOL[chartRow.countryCode]} />
+						</div>
+						<p className='world-map__modal-note'>실시간 차트 제공 · TradingView</p>
+					</div>
+				</div>
+			)}
 		</section>
 	);
 }

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

@@ -364,6 +364,76 @@
 		font-size: var(--fs-sm);
 		color: var(--text-muted);
 	}
+
+	// ── 차트 모달 (TradingView 드릴다운) ──
+	&__modal {
+		position: fixed;
+		inset: 0;
+		z-index: 1000;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		padding: var(--sp-5);
+		background: rgba(0, 0, 0, 0.55);
+	}
+
+	&__modal-panel {
+		display: flex;
+		flex-direction: column;
+		width: min(960px, 96vw);
+		height: min(620px, 88vh);
+		background: var(--bg-page);
+		border: 1px solid var(--border-strong);
+		border-radius: var(--radius);
+		overflow: hidden;
+	}
+
+	&__modal-head {
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		gap: var(--sp-3);
+		padding: var(--sp-3) var(--sp-4);
+		border-bottom: 1px solid var(--border-default);
+	}
+
+	&__modal-title {
+		font-size: var(--fs-base);
+		font-weight: 700;
+		color: var(--text-primary);
+	}
+
+	&__modal-close {
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+		width: 28px;
+		height: 28px;
+		background: transparent;
+		border: none;
+		border-radius: var(--radius);
+		font-size: var(--fs-2xl);
+		line-height: 1;
+		color: var(--text-muted);
+		cursor: pointer;
+
+		&:hover {
+			color: var(--text-primary);
+			background: var(--bg-subtle-hover);
+		}
+	}
+
+	&__modal-body {
+		flex: 1;
+		min-height: 0;
+	}
+
+	&__modal-note {
+		padding: var(--sp-2) var(--sp-4);
+		font-size: var(--fs-2xs);
+		color: var(--text-muted);
+		text-align: right;
+	}
 }
 
 @keyframes wm-pulse {

+ 18 - 0
types/worldIndex.ts

@@ -58,6 +58,24 @@ export const EXCHANGE_GEO: Record<string, ExchangeGeo> = {
 	BR: { lat: -23.5, lng: -46.6, label: '브라질', region: 'america' }
 };
 
+// ── 국가 → TradingView 대표지수 심볼 (드릴다운 차트). best-effort 매핑 ──
+// 없는 국가(예: CA)는 차트 버튼이 지도 하이라이트로 폴백. 심볼은 운영에서 조정 가능.
+export const TV_SYMBOL: Record<string, string> = {
+	US: 'SPX',
+	KR: 'KRX:KOSPI',
+	JP: 'TVC:NI225',
+	CN: 'SSE:000001',
+	HK: 'TVC:HSI',
+	TW: 'TVC:TWII',
+	SG: 'TVC:STI',
+	IN: 'BSE:SENSEX',
+	GB: 'TVC:UKX',
+	DE: 'XETR:DAX',
+	FR: 'EURONEXT:PX1',
+	AU: 'ASX:XJO',
+	BR: 'BMFBOVESPA:IBOV'
+};
+
 // ── 등락 상태 ──
 export type MoveDir = 'up' | 'down' | 'flat';