|
|
@@ -0,0 +1,188 @@
|
|
|
+'use client';
|
|
|
+
|
|
|
+import './world-map.scss';
|
|
|
+import { useMemo, useState } from 'react';
|
|
|
+import { EXCHANGE_GEO, moveDir, formatFlucRate, formatIndexClose, type WorldIndexRow } from '@/types/worldIndex';
|
|
|
+
|
|
|
+// 등거리(plate carrée) 투영 — viewBox 1000×500 (경도 -180~180, 위도 90~-90)
|
|
|
+const VW = 1000;
|
|
|
+const VH = 500;
|
|
|
+const project = (lat: number, lng: number) => ({
|
|
|
+ x: ((lng + 180) / 360) * VW,
|
|
|
+ y: ((90 - lat) / 180) * VH
|
|
|
+});
|
|
|
+
|
|
|
+// 스타일라이즈드(저폴리) 대륙 실루엣 — 지리적 근사(정밀 지도 아님). viewBox 좌표계 직접 좌표.
|
|
|
+const CONTINENTS: string[] = [
|
|
|
+ // 북미
|
|
|
+ '140,84 210,66 302,70 314,104 288,126 302,158 274,176 250,156 248,196 226,210 200,236 188,222 212,198 214,172 190,156 166,130 150,106',
|
|
|
+ // 그린란드
|
|
|
+ '330,54 366,48 380,74 352,92 332,76',
|
|
|
+ // 남미
|
|
|
+ '300,266 340,258 364,278 380,300 366,336 344,384 320,414 304,420 296,388 300,342 290,300 292,278',
|
|
|
+ // 유럽
|
|
|
+ '470,92 518,84 550,94 560,116 540,134 512,150 496,134 480,142 470,116',
|
|
|
+ // 아프리카
|
|
|
+ '486,176 548,166 588,188 596,236 570,300 542,358 520,350 508,298 500,244 488,206',
|
|
|
+ // 아시아(중동~러시아~중국~인도 벌지)
|
|
|
+ '560,92 634,68 714,70 792,82 858,98 900,120 872,140 852,150 872,178 830,198 800,178 810,214 776,234 748,208 720,180 700,206 686,182 656,168 628,146 600,150 580,128 566,110',
|
|
|
+ // 일본
|
|
|
+ '878,136 896,150 886,172 872,158',
|
|
|
+ // 인도네시아/동남아 도서
|
|
|
+ '756,234 806,230 830,248 800,264 760,258',
|
|
|
+ // 호주
|
|
|
+ '800,316 858,306 928,332 916,362 856,378 812,362 796,336'
|
|
|
+];
|
|
|
+
|
|
|
+// 위/경도 격자선
|
|
|
+const LNG_LINES = [-150, -120, -90, -60, -30, 0, 30, 60, 90, 120, 150];
|
|
|
+const LAT_LINES = [-60, -30, 0, 30, 60];
|
|
|
+
|
|
|
+type Props = {
|
|
|
+ rows: WorldIndexRow[];
|
|
|
+};
|
|
|
+
|
|
|
+type Pin = {
|
|
|
+ row: WorldIndexRow;
|
|
|
+ label: string;
|
|
|
+ x: number;
|
|
|
+ y: number;
|
|
|
+ dir: 'up' | 'down' | 'flat';
|
|
|
+ radius: number;
|
|
|
+};
|
|
|
+
|
|
|
+export default function WorldMarketMap({ rows }: Props)
|
|
|
+{
|
|
|
+ const [active, setActive] = useState<string|null>(null);
|
|
|
+
|
|
|
+ // 좌표를 아는 국가만 핀으로. 등락 크기에 따라 반경 가변(6~10).
|
|
|
+ const pins = useMemo<Pin[]>(() => {
|
|
|
+ return rows
|
|
|
+ .map((row) => {
|
|
|
+ const geo = EXCHANGE_GEO[row.countryCode];
|
|
|
+ if (!geo) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ const { x, y } = project(geo.lat, geo.lng);
|
|
|
+ const radius = Math.min(10, 6 + Math.abs(row.flucRateBp) / 80);
|
|
|
+ return { row, label: geo.label, x, y, dir: moveDir(row.flucRateBp), radius };
|
|
|
+ })
|
|
|
+ .filter((c): c is Pin => c !== null);
|
|
|
+ }, [rows]);
|
|
|
+
|
|
|
+ // 기준일 = 핀 중 가장 최근 거래일
|
|
|
+ const asOf = useMemo(() => {
|
|
|
+ return rows.reduce<string|null>((max, c) => (max === null || c.tradeDate > max ? c.tradeDate : max), null);
|
|
|
+ }, [rows]);
|
|
|
+
|
|
|
+ const activePin = pins.find((c) => c.row.countryCode === active) ?? null;
|
|
|
+
|
|
|
+ return (
|
|
|
+ <section className='world-map' aria-label='세계 주요국 증시지수'>
|
|
|
+ <div className='world-map__head'>
|
|
|
+ <h2 className='world-map__title'>세계 증시</h2>
|
|
|
+ <div className='world-map__meta'>
|
|
|
+ <span className='world-map__legend'>
|
|
|
+ <i className='world-map__dot world-map__dot--up' aria-hidden='true' />상승
|
|
|
+ <i className='world-map__dot world-map__dot--down' aria-hidden='true' />하락
|
|
|
+ </span>
|
|
|
+ {asOf && <span className='world-map__asof'>기준 {asOf}</span>}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className='world-map__stage'>
|
|
|
+ {pins.length === 0 ? (
|
|
|
+ <div className='world-map__empty'>표시할 지수 데이터가 없습니다.</div>
|
|
|
+ ) : (
|
|
|
+ <svg
|
|
|
+ className='world-map__svg'
|
|
|
+ viewBox={`0 0 ${VW} ${VH}`}
|
|
|
+ role='img'
|
|
|
+ aria-label='세계지도 위 주요국 증시지수 핀'
|
|
|
+ preserveAspectRatio='xMidYMid meet'
|
|
|
+ >
|
|
|
+ <defs>
|
|
|
+ <radialGradient id='wm-ocean' cx='50%' cy='38%' r='75%'>
|
|
|
+ <stop offset='0%' stopColor='var(--az-map-ocean-1)' />
|
|
|
+ <stop offset='100%' stopColor='var(--az-map-ocean-2)' />
|
|
|
+ </radialGradient>
|
|
|
+ <filter id='wm-glow' x='-60%' y='-60%' width='220%' height='220%'>
|
|
|
+ <feGaussianBlur stdDeviation='4' result='b' />
|
|
|
+ <feMerge>
|
|
|
+ <feMergeNode in='b' />
|
|
|
+ <feMergeNode in='SourceGraphic' />
|
|
|
+ </feMerge>
|
|
|
+ </filter>
|
|
|
+ </defs>
|
|
|
+
|
|
|
+ {/* 대양 */}
|
|
|
+ <rect x='0' y='0' width={VW} height={VH} rx='16' fill='url(#wm-ocean)' />
|
|
|
+
|
|
|
+ {/* 격자선 */}
|
|
|
+ <g className='world-map__grid'>
|
|
|
+ {LNG_LINES.map((lng) => {
|
|
|
+ const x = ((lng + 180) / 360) * VW;
|
|
|
+ return <line key={`lng-${lng}`} x1={x} y1='0' x2={x} y2={VH} />;
|
|
|
+ })}
|
|
|
+ {LAT_LINES.map((lat) => {
|
|
|
+ const y = ((90 - lat) / 180) * VH;
|
|
|
+ return <line key={`lat-${lat}`} x1='0' y1={y} x2={VW} y2={y} />;
|
|
|
+ })}
|
|
|
+ </g>
|
|
|
+
|
|
|
+ {/* 대륙 */}
|
|
|
+ <g className='world-map__land'>
|
|
|
+ {CONTINENTS.map((points, i) => (
|
|
|
+ <polygon key={i} points={points} />
|
|
|
+ ))}
|
|
|
+ </g>
|
|
|
+
|
|
|
+ {/* 핀 */}
|
|
|
+ <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))}
|
|
|
+ >
|
|
|
+ {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>
|
|
|
+ </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>
|
|
|
+ </section>
|
|
|
+ );
|
|
|
+}
|