WorldMarketMap.tsx 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. 'use client';
  2. import './world-map.scss';
  3. import { useMemo, useState } from 'react';
  4. import { EXCHANGE_GEO, moveDir, formatFlucRate, formatIndexClose, type WorldIndexRow } from '@/types/worldIndex';
  5. import { WORLD_LAND_PATH } from './worldLandPath';
  6. // 등거리(plate carrée) 투영 — viewBox 1000×400, 경도 -180~180 → x, 위도 84~-60 → y (남극 크롭, 등scale).
  7. // WORLD_LAND_PATH(실제 Natural Earth 110m 육지) 도 동일 투영으로 생성되어 핀과 정확히 정렬된다.
  8. const VW = 1000;
  9. const VH = 400;
  10. const LAT_MAX = 84;
  11. const LAT_SPAN = 144;
  12. const project = (lat: number, lng: number) => ({
  13. x: ((lng + 180) / 360) * VW,
  14. y: ((LAT_MAX - lat) / LAT_SPAN) * VH
  15. });
  16. // 위/경도 격자선 (크롭 위도 범위 내)
  17. const LNG_LINES = [-150, -120, -90, -60, -30, 0, 30, 60, 90, 120, 150];
  18. const LAT_LINES = [-40, -20, 0, 20, 40, 60];
  19. type Props = {
  20. rows: WorldIndexRow[];
  21. };
  22. type Pin = {
  23. row: WorldIndexRow;
  24. label: string;
  25. x: number;
  26. y: number;
  27. dir: 'up' | 'down' | 'flat';
  28. radius: number;
  29. };
  30. export default function WorldMarketMap({ rows }: Props)
  31. {
  32. const [active, setActive] = useState<string|null>(null);
  33. // 좌표를 아는 국가만 핀으로. 등락 크기에 따라 반경 가변(6~10).
  34. const pins = useMemo<Pin[]>(() => {
  35. return rows
  36. .map((row) => {
  37. const geo = EXCHANGE_GEO[row.countryCode];
  38. if (!geo) {
  39. return null;
  40. }
  41. const { x, y } = project(geo.lat, geo.lng);
  42. const radius = Math.min(10, 6 + Math.abs(row.flucRateBp) / 80);
  43. return { row, label: geo.label, x, y, dir: moveDir(row.flucRateBp), radius };
  44. })
  45. .filter((c): c is Pin => c !== null);
  46. }, [rows]);
  47. // 기준일 = 핀 중 가장 최근 거래일
  48. const asOf = useMemo(() => {
  49. return rows.reduce<string|null>((max, c) => (max === null || c.tradeDate > max ? c.tradeDate : max), null);
  50. }, [rows]);
  51. const activePin = pins.find((c) => c.row.countryCode === active) ?? null;
  52. return (
  53. <section className='world-map' aria-label='세계 주요국 증시지수'>
  54. <div className='world-map__head'>
  55. <h2 className='world-map__title'>세계 증시</h2>
  56. <div className='world-map__meta'>
  57. <span className='world-map__legend'>
  58. <i className='world-map__dot world-map__dot--up' aria-hidden='true' />상승
  59. <i className='world-map__dot world-map__dot--down' aria-hidden='true' />하락
  60. </span>
  61. {asOf && <span className='world-map__asof'>기준 {asOf}</span>}
  62. </div>
  63. </div>
  64. <div className='world-map__stage'>
  65. {pins.length === 0 ? (
  66. <div className='world-map__empty'>표시할 지수 데이터가 없습니다.</div>
  67. ) : (
  68. <svg
  69. className='world-map__svg'
  70. viewBox={`0 0 ${VW} ${VH}`}
  71. role='img'
  72. aria-label='세계지도 위 주요국 증시지수 핀'
  73. preserveAspectRatio='xMidYMid meet'
  74. >
  75. <defs>
  76. <radialGradient id='wm-ocean' cx='50%' cy='38%' r='75%'>
  77. <stop offset='0%' stopColor='var(--az-map-ocean-1)' />
  78. <stop offset='100%' stopColor='var(--az-map-ocean-2)' />
  79. </radialGradient>
  80. <filter id='wm-glow' x='-60%' y='-60%' width='220%' height='220%'>
  81. <feGaussianBlur stdDeviation='4' result='b' />
  82. <feMerge>
  83. <feMergeNode in='b' />
  84. <feMergeNode in='SourceGraphic' />
  85. </feMerge>
  86. </filter>
  87. </defs>
  88. {/* 대양 */}
  89. <rect x='0' y='0' width={VW} height={VH} rx='16' fill='url(#wm-ocean)' />
  90. {/* 격자선 */}
  91. <g className='world-map__grid'>
  92. {LNG_LINES.map((lng) => {
  93. const x = ((lng + 180) / 360) * VW;
  94. return <line key={`lng-${lng}`} x1={x} y1='0' x2={x} y2={VH} />;
  95. })}
  96. {LAT_LINES.map((lat) => {
  97. const y = ((LAT_MAX - lat) / LAT_SPAN) * VH;
  98. return <line key={`lat-${lat}`} x1='0' y1={y} x2={VW} y2={y} />;
  99. })}
  100. </g>
  101. {/* 대륙 (실제 Natural Earth 110m 육지) */}
  102. <g className='world-map__land'>
  103. <path d={WORLD_LAND_PATH} />
  104. </g>
  105. {/* 핀 */}
  106. <g className='world-map__pins'>
  107. {pins.map((pin) => {
  108. const isActive = pin.row.countryCode === active;
  109. return (
  110. <g
  111. key={pin.row.countryCode}
  112. className={`world-map__pin world-map__pin--${pin.dir}${isActive ? ' is-active' : ''}`}
  113. transform={`translate(${pin.x} ${pin.y})`}
  114. role='button'
  115. tabIndex={0}
  116. aria-label={`${pin.label} ${pin.row.name} ${formatFlucRate(pin.row.flucRateBp)}`}
  117. onMouseEnter={() => setActive(pin.row.countryCode)}
  118. onMouseLeave={() => setActive((cur) => (cur === pin.row.countryCode ? null : cur))}
  119. onFocus={() => setActive(pin.row.countryCode)}
  120. onBlur={() => setActive((cur) => (cur === pin.row.countryCode ? null : cur))}
  121. >
  122. {pin.dir !== 'flat' && <circle className='world-map__pulse' r={pin.radius} />}
  123. <circle className='world-map__pin-dot' r={pin.radius} />
  124. <circle className='world-map__pin-core' r={pin.radius / 2.6} />
  125. </g>
  126. );
  127. })}
  128. </g>
  129. </svg>
  130. )}
  131. {/* 툴팁 — 활성 핀 기준 % 위치 (viewBox 비율) */}
  132. {activePin && (
  133. <div
  134. className='world-map__tip'
  135. style={{ left: `${(activePin.x / VW) * 100}%`, top: `${(activePin.y / VH) * 100}%` }}
  136. role='status'
  137. >
  138. <div className='world-map__tip-head'>
  139. <span className='world-map__tip-country'>{activePin.label}</span>
  140. <span className='world-map__tip-name'>{activePin.row.name}</span>
  141. </div>
  142. <div className='world-map__tip-close'>{formatIndexClose(activePin.row.close)}</div>
  143. <div className={`world-map__tip-change world-map__tip-change--${activePin.dir}`}>
  144. {formatFlucRate(activePin.row.flucRateBp)}
  145. <span className='world-map__tip-exch'>{activePin.row.exchangeName}</span>
  146. </div>
  147. </div>
  148. )}
  149. </div>
  150. </section>
  151. );
  152. }