WorldMarketMap.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. 'use client';
  2. import './world-map.scss';
  3. import { useMemo, useRef, useState } from 'react';
  4. import { geoNaturalEarth1, geoPath } from 'd3-geo';
  5. import { feature } from 'topojson-client';
  6. import type { Feature, Geometry } from 'geojson';
  7. import worldTopo from 'world-atlas/countries-110m.json';
  8. import {
  9. EXCHANGE_GEO,
  10. REGION_LABELS,
  11. REGION_ORDER,
  12. moveDir,
  13. formatFlucRate,
  14. formatIndexClose,
  15. type WorldIndexRow,
  16. type MarketRegion,
  17. TV_SYMBOL
  18. } from '@/types/worldIndex';
  19. import TradingViewChart from './TradingViewChart';
  20. // 등거리 대신 d3-geo geoNaturalEarth1(곡면) 투영. 국가별 topojson feature 를 GeoJSON 으로 디코드해
  21. // 육지 path 와 핀 좌표를 "동일 projection" 으로 산출. 팬/줌은 <g> transform 으로 처리(드래그 이동·확대).
  22. // (Wave1 — .claude/plan/global-market-research-pages.md)
  23. const VW = 1000;
  24. const VH = 500;
  25. const MIN_K = 1;
  26. const MAX_K = 6;
  27. const DEFAULT_K = 1.5; // 기본 살짝 확대
  28. type GeoFeature = Feature<Geometry>;
  29. type RegionTab = 'major'|MarketRegion;
  30. type View = { x: number; y: number; k: number };
  31. const COUNTRIES: GeoFeature[] = (() => {
  32. const decoded = feature(worldTopo as never, (worldTopo as unknown as { objects: { countries: never } }).objects.countries) as unknown as { features: GeoFeature[] };
  33. return decoded.features.filter((f) => String(f.id) !== '010'); // 남극 제외
  34. })();
  35. const PROJECTION = geoNaturalEarth1().fitSize([VW, VH], { type: 'FeatureCollection', features: COUNTRIES } as never);
  36. const PATH_FN = geoPath(PROJECTION);
  37. const COUNTRY_PATHS: { id: string; d: string }[] = COUNTRIES
  38. .map((f) => ({ id: String(f.id), d: PATH_FN(f as never) ?? '' }))
  39. .filter((c) => c.d.length > 0);
  40. const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
  41. // viewBox 중심 기준으로 k 배 확대한 초기 view
  42. const centeredView = (k: number): View => ({ x: (VW / 2) * (1 - k), y: (VH / 2) * (1 - k), k });
  43. type Props = {
  44. rows: WorldIndexRow[];
  45. };
  46. type Pin = {
  47. row: WorldIndexRow;
  48. label: string;
  49. x: number;
  50. y: number;
  51. dir: 'up'|'down'|'flat';
  52. radius: number;
  53. };
  54. function formatChangeVal(changeVal: number): string {
  55. const sign = changeVal > 0 ? '+' : '';
  56. return `${sign}${changeVal.toLocaleString('ko-KR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
  57. }
  58. export default function WorldMarketMap({ rows }: Props)
  59. {
  60. const [region, setRegion] = useState<RegionTab>('major');
  61. const [active, setActive] = useState<string|null>(null);
  62. const [chartRow, setChartRow] = useState<WorldIndexRow|null>(null);
  63. const [view, setView] = useState<View>(() => centeredView(DEFAULT_K));
  64. const [panning, setPanning] = useState(false);
  65. const svgRef = useRef<SVGSVGElement>(null);
  66. const dragRef = useRef<{ px: number; py: number; vx: number; vy: number; moved: boolean }|null>(null);
  67. const suppressClickRef = useRef(false);
  68. // 좌표를 아는 국가만 취급 + 핀 좌표 산출
  69. const pins = useMemo<Pin[]>(() => {
  70. return rows
  71. .map((row) => {
  72. const geo = EXCHANGE_GEO[row.countryCode];
  73. if (!geo) {
  74. return null;
  75. }
  76. const xy = PROJECTION([geo.lng, geo.lat]);
  77. if (!xy) {
  78. return null;
  79. }
  80. const radius = Math.min(8, 4.5 + Math.abs(row.flucRateBp) / 110);
  81. return { row, label: geo.label, x: xy[0], y: xy[1], dir: moveDir(row.flucRateBp), radius };
  82. })
  83. .filter((c): c is Pin => c !== null);
  84. }, [rows]);
  85. // 지역 탭 (데이터 있는 탭만)
  86. const tabs = useMemo<RegionTab[]>(() => {
  87. const counts: Record<RegionTab, number> = { major: 0, asia: 0, europe: 0, america: 0, mideast: 0 };
  88. pins.forEach((p) => {
  89. const geo = EXCHANGE_GEO[p.row.countryCode];
  90. counts[geo.region] += 1;
  91. if (geo.major) {
  92. counts.major += 1;
  93. }
  94. });
  95. return REGION_ORDER.filter((t) => counts[t] > 0);
  96. }, [pins]);
  97. const activeRegion: RegionTab = tabs.includes(region) ? region : (tabs[0] ?? 'major');
  98. const gridRows = useMemo(() => {
  99. return pins
  100. .map((p) => p.row)
  101. .filter((r) => {
  102. const geo = EXCHANGE_GEO[r.countryCode];
  103. return activeRegion === 'major' ? geo.major === true : geo.region === activeRegion;
  104. })
  105. .sort((a, b) => b.flucRateBp - a.flucRateBp);
  106. }, [pins, activeRegion]);
  107. const asOf = useMemo(() => {
  108. return pins.reduce<string|null>((max, c) => (max === null || c.row.tradeDate > max ? c.row.tradeDate : max), null);
  109. }, [pins]);
  110. // 지역 탭 클릭 → 그리드 필터 + 지도를 해당 지역으로 이동/확대
  111. function selectRegion(t: RegionTab)
  112. {
  113. setRegion(t);
  114. if (t === 'major') {
  115. setView(centeredView(DEFAULT_K));
  116. return;
  117. }
  118. const regionPins = pins.filter((p) => EXCHANGE_GEO[p.row.countryCode].region === t);
  119. if (regionPins.length === 0) {
  120. return;
  121. }
  122. const xs = regionPins.map((p) => p.x);
  123. const ys = regionPins.map((p) => p.y);
  124. const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
  125. const cy = (Math.min(...ys) + Math.max(...ys)) / 2;
  126. const spreadX = Math.max(Math.max(...xs) - Math.min(...xs), 70);
  127. const spreadY = Math.max(Math.max(...ys) - Math.min(...ys), 70);
  128. const k = clamp(Math.min(VW / (spreadX * 2.2), VH / (spreadY * 2.2)), 1.6, 4);
  129. setView({ x: VW / 2 - cx * k, y: VH / 2 - cy * k, k });
  130. }
  131. function zoomBy(factor: number)
  132. {
  133. setView((v) => {
  134. const k = clamp(v.k * factor, MIN_K, MAX_K);
  135. const cx = VW / 2;
  136. const cy = VH / 2;
  137. return { x: cx - (cx - v.x) * (k / v.k), y: cy - (cy - v.y) * (k / v.k), k };
  138. });
  139. }
  140. function onPointerDown(e: React.PointerEvent<SVGSVGElement>)
  141. {
  142. if (e.button !== 0) {
  143. return;
  144. }
  145. dragRef.current = { px: e.clientX, py: e.clientY, vx: view.x, vy: view.y, moved: false };
  146. setPanning(true);
  147. }
  148. function onPointerMove(e: React.PointerEvent<SVGSVGElement>)
  149. {
  150. const d = dragRef.current;
  151. if (!d) {
  152. return;
  153. }
  154. const rect = svgRef.current?.getBoundingClientRect();
  155. const factor = rect && rect.width > 0 ? VW / rect.width : 1;
  156. const dx = (e.clientX - d.px) * factor;
  157. const dy = (e.clientY - d.py) * factor;
  158. if (Math.abs(e.clientX - d.px) + Math.abs(e.clientY - d.py) > 3) {
  159. d.moved = true;
  160. }
  161. setView((v) => ({ ...v, x: d.vx + dx, y: d.vy + dy }));
  162. }
  163. function endPan()
  164. {
  165. suppressClickRef.current = dragRef.current?.moved ?? false;
  166. dragRef.current = null;
  167. setPanning(false);
  168. }
  169. function onPinClick(row: WorldIndexRow)
  170. {
  171. if (suppressClickRef.current) {
  172. suppressClickRef.current = false;
  173. return;
  174. }
  175. if (TV_SYMBOL[row.countryCode]) {
  176. setChartRow(row);
  177. }
  178. }
  179. return (
  180. <section className='world-map' aria-label='세계 주요국 증시지수'>
  181. <div className='world-map__head'>
  182. <h2 className='world-map__title'>세계 증시</h2>
  183. <div className='world-map__meta'>
  184. <span className='world-map__legend'>
  185. <i className='world-map__dot world-map__dot--up' aria-hidden='true' />상승
  186. <i className='world-map__dot world-map__dot--down' aria-hidden='true' />하락
  187. </span>
  188. {asOf && <span className='world-map__asof'>전일 마감 기준 {asOf}</span>}
  189. </div>
  190. </div>
  191. <div className='world-map__body'>
  192. <div className='world-map__stage'>
  193. <svg
  194. ref={svgRef}
  195. className={`world-map__svg${panning ? ' is-panning' : ''}`}
  196. viewBox={`0 0 ${VW} ${VH}`}
  197. role='img'
  198. aria-label='세계지도 위 주요국 증시지수 핀 (드래그로 이동, 확대 가능)'
  199. preserveAspectRatio='xMidYMid meet'
  200. onPointerDown={onPointerDown}
  201. onPointerMove={onPointerMove}
  202. onPointerUp={endPan}
  203. onPointerLeave={endPan}
  204. >
  205. <defs>
  206. <radialGradient id='wm-ocean' cx='50%' cy='42%' r='75%'>
  207. <stop offset='0%' stopColor='var(--az-map-ocean-1)' />
  208. <stop offset='100%' stopColor='var(--az-map-ocean-2)' />
  209. </radialGradient>
  210. </defs>
  211. {/* 대양 (고정 배경 — 팬/줌 영향 없음) */}
  212. <rect x='0' y='0' width={VW} height={VH} rx='16' fill='url(#wm-ocean)' />
  213. {/* 팬/줌 그룹 */}
  214. <g transform={`translate(${view.x} ${view.y}) scale(${view.k})`}>
  215. <g className='world-map__land'>
  216. {COUNTRY_PATHS.map((c) => (
  217. <path key={c.id} d={c.d} />
  218. ))}
  219. </g>
  220. <g className='world-map__pins'>
  221. {pins.map((pin) => {
  222. const isActive = pin.row.countryCode === active;
  223. const leftSide = pin.x > VW * 0.72;
  224. const lx = leftSide ? -(pin.radius + 4) : pin.radius + 4;
  225. const anchor = leftSide ? 'end' : 'start';
  226. return (
  227. <g
  228. key={pin.row.countryCode}
  229. className={`world-map__pin world-map__pin--${pin.dir}${isActive ? ' is-active' : ''}`}
  230. transform={`translate(${pin.x} ${pin.y})`}
  231. role='button'
  232. tabIndex={0}
  233. aria-label={`${pin.label} ${pin.row.name} ${formatFlucRate(pin.row.flucRateBp)}`}
  234. onMouseEnter={() => setActive(pin.row.countryCode)}
  235. onMouseLeave={() => setActive((cur) => (cur === pin.row.countryCode ? null : cur))}
  236. onFocus={() => setActive(pin.row.countryCode)}
  237. onBlur={() => setActive((cur) => (cur === pin.row.countryCode ? null : cur))}
  238. onClick={() => onPinClick(pin.row)}
  239. onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && TV_SYMBOL[pin.row.countryCode]) { e.preventDefault(); setChartRow(pin.row); } }}
  240. >
  241. {pin.dir !== 'flat' && <circle className='world-map__pulse' r={pin.radius} />}
  242. <circle className='world-map__pin-dot' r={pin.radius} />
  243. <circle className='world-map__pin-core' r={pin.radius / 2.6} />
  244. {/* 상시 라벨 — 국가명 + 등락률 (핀 옆) */}
  245. <g className='world-map__label' transform={`translate(${lx} 0)`} textAnchor={anchor}>
  246. <text className='world-map__label-name' y='-1.5'>{pin.label}</text>
  247. <text className={`world-map__label-rate world-map__label-rate--${pin.dir}`} y='9'>{formatFlucRate(pin.row.flucRateBp)}</text>
  248. {isActive && <text className='world-map__label-close' y='19'>{formatIndexClose(pin.row.close)}</text>}
  249. </g>
  250. </g>
  251. );
  252. })}
  253. </g>
  254. </g>
  255. </svg>
  256. {/* 줌 컨트롤 */}
  257. <div className='world-map__zoom'>
  258. <button type='button' className='world-map__zoom-btn' aria-label='확대' onClick={() => zoomBy(1.4)}>+</button>
  259. <button type='button' className='world-map__zoom-btn' aria-label='축소' onClick={() => zoomBy(1 / 1.4)}>−</button>
  260. <button type='button' className='world-map__zoom-btn' aria-label='초기화' title='초기화' onClick={() => setView(centeredView(DEFAULT_K))}>⤢</button>
  261. </div>
  262. </div>
  263. {/* 지수 패널 — 지역 탭 + 데이터 그리드 */}
  264. <div className='world-map__panel'>
  265. <div className='world-map__tabs' role='tablist' aria-label='지역'>
  266. {tabs.map((t) => (
  267. <button
  268. key={t}
  269. type='button'
  270. role='tab'
  271. aria-selected={t === activeRegion}
  272. className={`world-map__tab${t === activeRegion ? ' is-active' : ''}`}
  273. onClick={() => selectRegion(t)}
  274. >
  275. {REGION_LABELS[t]}
  276. </button>
  277. ))}
  278. </div>
  279. <div className='world-map__grid-wrap'>
  280. <table className='world-map__grid'>
  281. <thead>
  282. <tr>
  283. <th scope='col' className='world-map__c-name'>지수명</th>
  284. <th scope='col' className='world-map__c-num'>지수</th>
  285. <th scope='col' className='world-map__c-num'>전일비</th>
  286. <th scope='col' className='world-map__c-num'>등락률</th>
  287. <th scope='col' className='world-map__c-time'>시간</th>
  288. <th scope='col' className='world-map__c-chart'>차트</th>
  289. </tr>
  290. </thead>
  291. <tbody>
  292. {gridRows.map((row) => {
  293. const geo = EXCHANGE_GEO[row.countryCode];
  294. const dir = moveDir(row.flucRateBp);
  295. const isActive = row.countryCode === active;
  296. return (
  297. <tr
  298. key={row.countryCode}
  299. className={`world-map__row${isActive ? ' is-active' : ''}`}
  300. onMouseEnter={() => setActive(row.countryCode)}
  301. onMouseLeave={() => setActive((cur) => (cur === row.countryCode ? null : cur))}
  302. >
  303. <td className='world-map__c-name'>
  304. <span className='world-map__row-country'>{geo.label}</span>
  305. <span className='world-map__row-name'>{row.name}</span>
  306. </td>
  307. <td className='world-map__c-num world-map__c-close'>{formatIndexClose(row.close)}</td>
  308. <td className={`world-map__c-num world-map__c-${dir}`}>{formatChangeVal(row.changeVal)}</td>
  309. <td className={`world-map__c-num world-map__c-${dir}`}>{formatFlucRate(row.flucRateBp)}</td>
  310. <td className='world-map__c-time'>{row.tradeDate}</td>
  311. <td className='world-map__c-chart'>
  312. <button
  313. type='button'
  314. className='world-map__chart-btn'
  315. title={TV_SYMBOL[row.countryCode] ? `${row.name} 차트 보기` : `${geo.label} 위치 보기`}
  316. aria-label={TV_SYMBOL[row.countryCode] ? `${geo.label} ${row.name} 차트 보기` : `${geo.label} ${row.name} 지도에서 보기`}
  317. onClick={() => { if (TV_SYMBOL[row.countryCode]) { setChartRow(row); } else { setActive(row.countryCode); } }}
  318. >
  319. <svg viewBox='0 0 24 24' width='16' height='16' aria-hidden='true'>
  320. <polyline points='3,17 9,11 13,15 21,6' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round' strokeLinejoin='round' />
  321. </svg>
  322. </button>
  323. </td>
  324. </tr>
  325. );
  326. })}
  327. </tbody>
  328. </table>
  329. {gridRows.length === 0 && <div className='world-map__empty'>이 지역 지수 데이터가 없습니다.</div>}
  330. </div>
  331. </div>
  332. </div>
  333. {chartRow && TV_SYMBOL[chartRow.countryCode] && (
  334. <div
  335. className='world-map__modal'
  336. role='dialog'
  337. aria-modal='true'
  338. aria-label={`${EXCHANGE_GEO[chartRow.countryCode]?.label ?? ''} ${chartRow.name} 차트`}
  339. onClick={() => setChartRow(null)}
  340. >
  341. <div className='world-map__modal-panel' onClick={(e) => e.stopPropagation()}>
  342. <div className='world-map__modal-head'>
  343. <span className='world-map__modal-title'>
  344. {EXCHANGE_GEO[chartRow.countryCode]?.label} · {chartRow.name}
  345. </span>
  346. <button type='button' className='world-map__modal-close' aria-label='닫기' onClick={() => setChartRow(null)}>×</button>
  347. </div>
  348. <div className='world-map__modal-body'>
  349. <TradingViewChart symbol={TV_SYMBOL[chartRow.countryCode]} />
  350. </div>
  351. <p className='world-map__modal-note'>실시간 차트 제공 · TradingView</p>
  352. </div>
  353. </div>
  354. )}
  355. </section>
  356. );
  357. }