WorldMarketMap.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. 'use client';
  2. import './world-map.scss';
  3. import { useEffect, useMemo, 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. // 등거리(plate carrée) 대신 d3-geo geoNaturalEarth1(곡면) 투영.
  21. // 국가별 topojson feature 를 GeoJSON 으로 디코드해 육지 path 와 핀 좌표를 "동일 projection" 으로 산출 → 오정렬 원천 차단.
  22. // (Wave1 — .claude/plan/global-market-research-pages.md)
  23. const VW = 1000;
  24. const VH = 500;
  25. type GeoFeature = Feature<Geometry>;
  26. type RegionTab = 'major'|MarketRegion;
  27. // ── 지도 지오메트리: 정적 topojson → GeoJSON feature. 모듈 로드 시 1회 계산(남극 제외) ──
  28. const COUNTRIES: GeoFeature[] = (() => {
  29. const decoded = feature(worldTopo as never, (worldTopo as unknown as { objects: { countries: never } }).objects.countries) as unknown as { features: GeoFeature[] };
  30. return decoded.features.filter((f) => String(f.id) !== '010'); // 남극(ATA) 제외 — 하단 여백 방지
  31. })();
  32. const PROJECTION = geoNaturalEarth1().fitSize([VW, VH], { type: 'FeatureCollection', features: COUNTRIES } as never);
  33. const PATH_FN = geoPath(PROJECTION);
  34. const COUNTRY_PATHS: { id: string; d: string }[] = COUNTRIES
  35. .map((f) => ({ id: String(f.id), d: PATH_FN(f as never) ?? '' }))
  36. .filter((c) => c.d.length > 0);
  37. type Props = {
  38. rows: WorldIndexRow[];
  39. };
  40. type Pin = {
  41. row: WorldIndexRow;
  42. label: string;
  43. x: number;
  44. y: number;
  45. dir: 'up'|'down'|'flat';
  46. radius: number;
  47. };
  48. function formatChangeVal(changeVal: number): string {
  49. const sign = changeVal > 0 ? '+' : '';
  50. return `${sign}${changeVal.toLocaleString('ko-KR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
  51. }
  52. export default function WorldMarketMap({ rows }: Props)
  53. {
  54. const [region, setRegion] = useState<RegionTab>('major');
  55. const [active, setActive] = useState<string|null>(null);
  56. const [chartRow, setChartRow] = useState<WorldIndexRow|null>(null);
  57. // 차트 모달 Esc 닫기
  58. useEffect(() => {
  59. if (!chartRow) {
  60. return;
  61. }
  62. const onKey = (e: KeyboardEvent) => {
  63. if (e.key === 'Escape') {
  64. setChartRow(null);
  65. }
  66. };
  67. window.addEventListener('keydown', onKey);
  68. return () => {
  69. window.removeEventListener('keydown', onKey);
  70. };
  71. }, [chartRow]);
  72. // 좌표를 아는 국가만 취급
  73. const geoRows = useMemo(() => {
  74. return rows.filter((r) => EXCHANGE_GEO[r.countryCode]);
  75. }, [rows]);
  76. // 핀 — 등락 크기에 따라 반경 가변(5~9), projection 으로 좌표 산출
  77. const pins = useMemo<Pin[]>(() => {
  78. return geoRows
  79. .map((row) => {
  80. const geo = EXCHANGE_GEO[row.countryCode];
  81. const xy = PROJECTION([geo.lng, geo.lat]);
  82. if (!xy) {
  83. return null;
  84. }
  85. const radius = Math.min(9, 5 + Math.abs(row.flucRateBp) / 90);
  86. return { row, label: geo.label, x: xy[0], y: xy[1], dir: moveDir(row.flucRateBp), radius };
  87. })
  88. .filter((c): c is Pin => c !== null);
  89. }, [geoRows]);
  90. // 지역 탭 — 데이터 있는 탭만 노출(무음 빈 탭 방지). major = 주요국 큐레이션.
  91. const tabs = useMemo<RegionTab[]>(() => {
  92. const counts: Record<RegionTab, number> = { major: 0, asia: 0, europe: 0, america: 0, mideast: 0 };
  93. geoRows.forEach((r) => {
  94. const geo = EXCHANGE_GEO[r.countryCode];
  95. counts[geo.region] += 1;
  96. if (geo.major) {
  97. counts.major += 1;
  98. }
  99. });
  100. return REGION_ORDER.filter((t) => counts[t] > 0);
  101. }, [geoRows]);
  102. // 선택 탭이 데이터 없는 탭이면 첫 탭으로 폴백
  103. const activeRegion: RegionTab = tabs.includes(region) ? region : (tabs[0] ?? 'major');
  104. // 그리드 = 선택 지역 필터 + 등락률 내림차순
  105. const gridRows = useMemo(() => {
  106. return geoRows
  107. .filter((r) => {
  108. const geo = EXCHANGE_GEO[r.countryCode];
  109. return activeRegion === 'major' ? geo.major === true : geo.region === activeRegion;
  110. })
  111. .sort((a, b) => b.flucRateBp - a.flucRateBp);
  112. }, [geoRows, activeRegion]);
  113. // 기준일 = 가장 최근 거래일
  114. const asOf = useMemo(() => {
  115. return geoRows.reduce<string|null>((max, c) => (max === null || c.tradeDate > max ? c.tradeDate : max), null);
  116. }, [geoRows]);
  117. const activePin = pins.find((c) => c.row.countryCode === active) ?? null;
  118. return (
  119. <section className='world-map' aria-label='세계 주요국 증시지수'>
  120. <div className='world-map__head'>
  121. <h2 className='world-map__title'>세계 증시</h2>
  122. <div className='world-map__meta'>
  123. <span className='world-map__legend'>
  124. <i className='world-map__dot world-map__dot--up' aria-hidden='true' />상승
  125. <i className='world-map__dot world-map__dot--down' aria-hidden='true' />하락
  126. </span>
  127. {asOf && <span className='world-map__asof'>전일 마감 기준 {asOf}</span>}
  128. </div>
  129. </div>
  130. <div className='world-map__body'>
  131. {/* 지도 */}
  132. <div className='world-map__stage'>
  133. <svg
  134. className='world-map__svg'
  135. viewBox={`0 0 ${VW} ${VH}`}
  136. role='img'
  137. aria-label='세계지도 위 주요국 증시지수 핀'
  138. preserveAspectRatio='xMidYMid meet'
  139. >
  140. <defs>
  141. <radialGradient id='wm-ocean' cx='50%' cy='42%' r='75%'>
  142. <stop offset='0%' stopColor='var(--az-map-ocean-1)' />
  143. <stop offset='100%' stopColor='var(--az-map-ocean-2)' />
  144. </radialGradient>
  145. </defs>
  146. {/* 대양 배경 */}
  147. <rect x='0' y='0' width={VW} height={VH} rx='16' fill='url(#wm-ocean)' />
  148. {/* 대륙 (국가별 topojson feature) */}
  149. <g className='world-map__land'>
  150. {COUNTRY_PATHS.map((c) => (
  151. <path key={c.id} d={c.d} />
  152. ))}
  153. </g>
  154. {/* 핀 */}
  155. <g className='world-map__pins'>
  156. {pins.map((pin) => {
  157. const isActive = pin.row.countryCode === active;
  158. return (
  159. <g
  160. key={pin.row.countryCode}
  161. className={`world-map__pin world-map__pin--${pin.dir}${isActive ? ' is-active' : ''}`}
  162. transform={`translate(${pin.x} ${pin.y})`}
  163. role='button'
  164. tabIndex={0}
  165. aria-label={`${pin.label} ${pin.row.name} ${formatFlucRate(pin.row.flucRateBp)}`}
  166. onMouseEnter={() => setActive(pin.row.countryCode)}
  167. onMouseLeave={() => setActive((cur) => (cur === pin.row.countryCode ? null : cur))}
  168. onFocus={() => setActive(pin.row.countryCode)}
  169. onBlur={() => setActive((cur) => (cur === pin.row.countryCode ? null : cur))}
  170. onClick={() => { if (TV_SYMBOL[pin.row.countryCode]) { setChartRow(pin.row); } }}
  171. >
  172. {pin.dir !== 'flat' && <circle className='world-map__pulse' r={pin.radius} />}
  173. <circle className='world-map__pin-dot' r={pin.radius} />
  174. <circle className='world-map__pin-core' r={pin.radius / 2.6} />
  175. </g>
  176. );
  177. })}
  178. </g>
  179. </svg>
  180. {/* 툴팁 — 활성 핀 기준 % 위치 (viewBox 비율) */}
  181. {activePin && (
  182. <div
  183. className='world-map__tip'
  184. style={{ left: `${(activePin.x / VW) * 100}%`, top: `${(activePin.y / VH) * 100}%` }}
  185. role='status'
  186. >
  187. <div className='world-map__tip-head'>
  188. <span className='world-map__tip-country'>{activePin.label}</span>
  189. <span className='world-map__tip-name'>{activePin.row.name}</span>
  190. </div>
  191. <div className='world-map__tip-close'>{formatIndexClose(activePin.row.close)}</div>
  192. <div className={`world-map__tip-change world-map__tip-change--${activePin.dir}`}>
  193. {formatFlucRate(activePin.row.flucRateBp)}
  194. <span className='world-map__tip-exch'>{activePin.row.exchangeName}</span>
  195. </div>
  196. </div>
  197. )}
  198. </div>
  199. {/* 지수 패널 — 지역 탭 + 데이터 그리드 (지도와 연동) */}
  200. <div className='world-map__panel'>
  201. <div className='world-map__tabs' role='tablist' aria-label='지역'>
  202. {tabs.map((t) => (
  203. <button
  204. key={t}
  205. type='button'
  206. role='tab'
  207. aria-selected={t === activeRegion}
  208. className={`world-map__tab${t === activeRegion ? ' is-active' : ''}`}
  209. onClick={() => setRegion(t)}
  210. >
  211. {REGION_LABELS[t]}
  212. </button>
  213. ))}
  214. </div>
  215. <div className='world-map__grid-wrap'>
  216. <table className='world-map__grid'>
  217. <thead>
  218. <tr>
  219. <th scope='col' className='world-map__c-name'>지수명</th>
  220. <th scope='col' className='world-map__c-num'>지수</th>
  221. <th scope='col' className='world-map__c-num'>전일비</th>
  222. <th scope='col' className='world-map__c-num'>등락률</th>
  223. <th scope='col' className='world-map__c-time'>시간</th>
  224. <th scope='col' className='world-map__c-chart'>차트</th>
  225. </tr>
  226. </thead>
  227. <tbody>
  228. {gridRows.map((row) => {
  229. const geo = EXCHANGE_GEO[row.countryCode];
  230. const dir = moveDir(row.flucRateBp);
  231. const isActive = row.countryCode === active;
  232. return (
  233. <tr
  234. key={row.countryCode}
  235. className={`world-map__row${isActive ? ' is-active' : ''}`}
  236. onMouseEnter={() => setActive(row.countryCode)}
  237. onMouseLeave={() => setActive((cur) => (cur === row.countryCode ? null : cur))}
  238. >
  239. <td className='world-map__c-name'>
  240. <span className='world-map__row-country'>{geo.label}</span>
  241. <span className='world-map__row-name'>{row.name}</span>
  242. </td>
  243. <td className='world-map__c-num world-map__c-close'>{formatIndexClose(row.close)}</td>
  244. <td className={`world-map__c-num world-map__c-${dir}`}>{formatChangeVal(row.changeVal)}</td>
  245. <td className={`world-map__c-num world-map__c-${dir}`}>{formatFlucRate(row.flucRateBp)}</td>
  246. <td className='world-map__c-time'>{row.tradeDate}</td>
  247. <td className='world-map__c-chart'>
  248. <button
  249. type='button'
  250. className='world-map__chart-btn'
  251. title={TV_SYMBOL[row.countryCode] ? `${row.name} 차트 보기` : `${geo.label} 위치 보기`}
  252. aria-label={TV_SYMBOL[row.countryCode] ? `${geo.label} ${row.name} 차트 보기` : `${geo.label} ${row.name} 지도에서 보기`}
  253. onClick={() => { if (TV_SYMBOL[row.countryCode]) { setChartRow(row); } else { setActive(row.countryCode); } }}
  254. >
  255. <svg viewBox='0 0 24 24' width='16' height='16' aria-hidden='true'>
  256. <polyline points='3,17 9,11 13,15 21,6' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round' strokeLinejoin='round' />
  257. </svg>
  258. </button>
  259. </td>
  260. </tr>
  261. );
  262. })}
  263. </tbody>
  264. </table>
  265. {gridRows.length === 0 && <div className='world-map__empty'>이 지역 지수 데이터가 없습니다.</div>}
  266. </div>
  267. </div>
  268. </div>
  269. {chartRow && TV_SYMBOL[chartRow.countryCode] && (
  270. <div
  271. className='world-map__modal'
  272. role='dialog'
  273. aria-modal='true'
  274. aria-label={`${EXCHANGE_GEO[chartRow.countryCode]?.label ?? ''} ${chartRow.name} 차트`}
  275. onClick={() => setChartRow(null)}
  276. >
  277. <div className='world-map__modal-panel' onClick={(e) => e.stopPropagation()}>
  278. <div className='world-map__modal-head'>
  279. <span className='world-map__modal-title'>
  280. {EXCHANGE_GEO[chartRow.countryCode]?.label} · {chartRow.name}
  281. </span>
  282. <button type='button' className='world-map__modal-close' aria-label='닫기' onClick={() => setChartRow(null)}>×</button>
  283. </div>
  284. <div className='world-map__modal-body'>
  285. <TradingViewChart symbol={TV_SYMBOL[chartRow.countryCode]} />
  286. </div>
  287. <p className='world-map__modal-note'>실시간 차트 제공 · TradingView</p>
  288. </div>
  289. </div>
  290. )}
  291. </section>
  292. );
  293. }