|
|
@@ -2,23 +2,41 @@
|
|
|
|
|
|
import './world-map.scss';
|
|
|
import { useMemo, useState } from 'react';
|
|
|
-import { EXCHANGE_GEO, moveDir, formatFlucRate, formatIndexClose, type WorldIndexRow } from '@/types/worldIndex';
|
|
|
-import { WORLD_LAND_PATH } from './worldLandPath';
|
|
|
+import { geoNaturalEarth1, geoPath } from 'd3-geo';
|
|
|
+import { feature } from 'topojson-client';
|
|
|
+import type { Feature, Geometry } from 'geojson';
|
|
|
+import worldTopo from 'world-atlas/countries-110m.json';
|
|
|
+import {
|
|
|
+ EXCHANGE_GEO,
|
|
|
+ REGION_LABELS,
|
|
|
+ REGION_ORDER,
|
|
|
+ moveDir,
|
|
|
+ formatFlucRate,
|
|
|
+ formatIndexClose,
|
|
|
+ type WorldIndexRow,
|
|
|
+ type MarketRegion
|
|
|
+} from '@/types/worldIndex';
|
|
|
|
|
|
-// 등거리(plate carrée) 투영 — viewBox 1000×400, 경도 -180~180 → x, 위도 84~-60 → y (남극 크롭, 등scale).
|
|
|
-// WORLD_LAND_PATH(실제 Natural Earth 110m 육지) 도 동일 투영으로 생성되어 핀과 정확히 정렬된다.
|
|
|
+// 등거리(plate carrée) 대신 d3-geo geoNaturalEarth1(곡면) 투영.
|
|
|
+// 국가별 topojson feature 를 GeoJSON 으로 디코드해 육지 path 와 핀 좌표를 "동일 projection" 으로 산출 → 오정렬 원천 차단.
|
|
|
+// (Wave1 — .claude/plan/global-market-research-pages.md)
|
|
|
const VW = 1000;
|
|
|
-const VH = 400;
|
|
|
-const LAT_MAX = 84;
|
|
|
-const LAT_SPAN = 144;
|
|
|
-const project = (lat: number, lng: number) => ({
|
|
|
- x: ((lng + 180) / 360) * VW,
|
|
|
- y: ((LAT_MAX - lat) / LAT_SPAN) * VH
|
|
|
-});
|
|
|
-
|
|
|
-// 위/경도 격자선 (크롭 위도 범위 내)
|
|
|
-const LNG_LINES = [-150, -120, -90, -60, -30, 0, 30, 60, 90, 120, 150];
|
|
|
-const LAT_LINES = [-40, -20, 0, 20, 40, 60];
|
|
|
+const VH = 500;
|
|
|
+
|
|
|
+type GeoFeature = Feature<Geometry>;
|
|
|
+type RegionTab = 'major'|MarketRegion;
|
|
|
+
|
|
|
+// ── 지도 지오메트리: 정적 topojson → GeoJSON feature. 모듈 로드 시 1회 계산(남극 제외) ──
|
|
|
+const COUNTRIES: GeoFeature[] = (() => {
|
|
|
+ const decoded = feature(worldTopo as never, (worldTopo as unknown as { objects: { countries: never } }).objects.countries) as unknown as { features: GeoFeature[] };
|
|
|
+ return decoded.features.filter((f) => String(f.id) !== '010'); // 남극(ATA) 제외 — 하단 여백 방지
|
|
|
+})();
|
|
|
+
|
|
|
+const PROJECTION = geoNaturalEarth1().fitSize([VW, VH], { type: 'FeatureCollection', features: COUNTRIES } as never);
|
|
|
+const PATH_FN = geoPath(PROJECTION);
|
|
|
+const COUNTRY_PATHS: { id: string; d: string }[] = COUNTRIES
|
|
|
+ .map((f) => ({ id: String(f.id), d: PATH_FN(f as never) ?? '' }))
|
|
|
+ .filter((c) => c.d.length > 0);
|
|
|
|
|
|
type Props = {
|
|
|
rows: WorldIndexRow[];
|
|
|
@@ -29,33 +47,70 @@ type Pin = {
|
|
|
label: string;
|
|
|
x: number;
|
|
|
y: number;
|
|
|
- dir: 'up' | 'down' | 'flat';
|
|
|
+ dir: 'up'|'down'|'flat';
|
|
|
radius: number;
|
|
|
};
|
|
|
|
|
|
+function formatChangeVal(changeVal: number): string {
|
|
|
+ const sign = changeVal > 0 ? '+' : '';
|
|
|
+ return `${sign}${changeVal.toLocaleString('ko-KR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
|
+}
|
|
|
+
|
|
|
export default function WorldMarketMap({ rows }: Props)
|
|
|
{
|
|
|
+ const [region, setRegion] = useState<RegionTab>('major');
|
|
|
const [active, setActive] = useState<string|null>(null);
|
|
|
|
|
|
- // 좌표를 아는 국가만 핀으로. 등락 크기에 따라 반경 가변(6~10).
|
|
|
+ // 좌표를 아는 국가만 취급
|
|
|
+ const geoRows = useMemo(() => {
|
|
|
+ return rows.filter((r) => EXCHANGE_GEO[r.countryCode]);
|
|
|
+ }, [rows]);
|
|
|
+
|
|
|
+ // 핀 — 등락 크기에 따라 반경 가변(5~9), projection 으로 좌표 산출
|
|
|
const pins = useMemo<Pin[]>(() => {
|
|
|
- return rows
|
|
|
+ return geoRows
|
|
|
.map((row) => {
|
|
|
const geo = EXCHANGE_GEO[row.countryCode];
|
|
|
- if (!geo) {
|
|
|
+ const xy = PROJECTION([geo.lng, geo.lat]);
|
|
|
+ if (!xy) {
|
|
|
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 };
|
|
|
+ const radius = Math.min(9, 5 + Math.abs(row.flucRateBp) / 90);
|
|
|
+ return { row, label: geo.label, x: xy[0], y: xy[1], dir: moveDir(row.flucRateBp), radius };
|
|
|
})
|
|
|
.filter((c): c is Pin => c !== null);
|
|
|
- }, [rows]);
|
|
|
+ }, [geoRows]);
|
|
|
+
|
|
|
+ // 지역 탭 — 데이터 있는 탭만 노출(무음 빈 탭 방지). major = 주요국 큐레이션.
|
|
|
+ const tabs = useMemo<RegionTab[]>(() => {
|
|
|
+ const counts: Record<RegionTab, number> = { major: 0, asia: 0, europe: 0, america: 0, mideast: 0 };
|
|
|
+ geoRows.forEach((r) => {
|
|
|
+ const geo = EXCHANGE_GEO[r.countryCode];
|
|
|
+ counts[geo.region] += 1;
|
|
|
+ if (geo.major) {
|
|
|
+ counts.major += 1;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return REGION_ORDER.filter((t) => counts[t] > 0);
|
|
|
+ }, [geoRows]);
|
|
|
+
|
|
|
+ // 선택 탭이 데이터 없는 탭이면 첫 탭으로 폴백
|
|
|
+ const activeRegion: RegionTab = tabs.includes(region) ? region : (tabs[0] ?? 'major');
|
|
|
|
|
|
- // 기준일 = 핀 중 가장 최근 거래일
|
|
|
+ // 그리드 = 선택 지역 필터 + 등락률 내림차순
|
|
|
+ const gridRows = useMemo(() => {
|
|
|
+ return geoRows
|
|
|
+ .filter((r) => {
|
|
|
+ const geo = EXCHANGE_GEO[r.countryCode];
|
|
|
+ return activeRegion === 'major' ? geo.major === true : geo.region === activeRegion;
|
|
|
+ })
|
|
|
+ .sort((a, b) => b.flucRateBp - a.flucRateBp);
|
|
|
+ }, [geoRows, activeRegion]);
|
|
|
+
|
|
|
+ // 기준일 = 가장 최근 거래일
|
|
|
const asOf = useMemo(() => {
|
|
|
- return rows.reduce<string|null>((max, c) => (max === null || c.tradeDate > max ? c.tradeDate : max), null);
|
|
|
- }, [rows]);
|
|
|
+ return geoRows.reduce<string|null>((max, c) => (max === null || c.tradeDate > max ? c.tradeDate : max), null);
|
|
|
+ }, [geoRows]);
|
|
|
|
|
|
const activePin = pins.find((c) => c.row.countryCode === active) ?? null;
|
|
|
|
|
|
@@ -68,14 +123,13 @@ export default function WorldMarketMap({ rows }: Props)
|
|
|
<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>}
|
|
|
+ {asOf && <span className='world-map__asof'>전일 마감 기준 {asOf}</span>}
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <div className='world-map__stage'>
|
|
|
- {pins.length === 0 ? (
|
|
|
- <div className='world-map__empty'>표시할 지수 데이터가 없습니다.</div>
|
|
|
- ) : (
|
|
|
+ <div className='world-map__body'>
|
|
|
+ {/* 지도 */}
|
|
|
+ <div className='world-map__stage'>
|
|
|
<svg
|
|
|
className='world-map__svg'
|
|
|
viewBox={`0 0 ${VW} ${VH}`}
|
|
|
@@ -84,37 +138,20 @@ export default function WorldMarketMap({ rows }: Props)
|
|
|
preserveAspectRatio='xMidYMid meet'
|
|
|
>
|
|
|
<defs>
|
|
|
- <radialGradient id='wm-ocean' cx='50%' cy='38%' r='75%'>
|
|
|
+ <radialGradient id='wm-ocean' cx='50%' cy='42%' 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 = ((LAT_MAX - lat) / LAT_SPAN) * VH;
|
|
|
- return <line key={`lat-${lat}`} x1='0' y1={y} x2={VW} y2={y} />;
|
|
|
- })}
|
|
|
- </g>
|
|
|
-
|
|
|
- {/* 대륙 (실제 Natural Earth 110m 육지) */}
|
|
|
+ {/* 대륙 (국가별 topojson feature) */}
|
|
|
<g className='world-map__land'>
|
|
|
- <path d={WORLD_LAND_PATH} />
|
|
|
+ {COUNTRY_PATHS.map((c) => (
|
|
|
+ <path key={c.id} d={c.d} />
|
|
|
+ ))}
|
|
|
</g>
|
|
|
|
|
|
{/* 핀 */}
|
|
|
@@ -142,26 +179,97 @@ export default function WorldMarketMap({ rows }: Props)
|
|
|
})}
|
|
|
</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>
|
|
|
+
|
|
|
+ {/* 툴팁 — 활성 핀 기준 % 위치 (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>
|
|
|
+
|
|
|
+ {/* 지수 패널 — 지역 탭 + 데이터 그리드 (지도와 연동) */}
|
|
|
+ <div className='world-map__panel'>
|
|
|
+ <div className='world-map__tabs' role='tablist' aria-label='지역'>
|
|
|
+ {tabs.map((t) => (
|
|
|
+ <button
|
|
|
+ key={t}
|
|
|
+ type='button'
|
|
|
+ role='tab'
|
|
|
+ aria-selected={t === activeRegion}
|
|
|
+ className={`world-map__tab${t === activeRegion ? ' is-active' : ''}`}
|
|
|
+ onClick={() => setRegion(t)}
|
|
|
+ >
|
|
|
+ {REGION_LABELS[t]}
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
</div>
|
|
|
- )}
|
|
|
+
|
|
|
+ <div className='world-map__grid-wrap'>
|
|
|
+ <table className='world-map__grid'>
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th scope='col' className='world-map__c-name'>지수명</th>
|
|
|
+ <th scope='col' className='world-map__c-num'>지수</th>
|
|
|
+ <th scope='col' className='world-map__c-num'>전일비</th>
|
|
|
+ <th scope='col' className='world-map__c-num'>등락률</th>
|
|
|
+ <th scope='col' className='world-map__c-time'>시간</th>
|
|
|
+ <th scope='col' className='world-map__c-chart'>차트</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ {gridRows.map((row) => {
|
|
|
+ const geo = EXCHANGE_GEO[row.countryCode];
|
|
|
+ const dir = moveDir(row.flucRateBp);
|
|
|
+ const isActive = row.countryCode === active;
|
|
|
+ return (
|
|
|
+ <tr
|
|
|
+ key={row.countryCode}
|
|
|
+ className={`world-map__row${isActive ? ' is-active' : ''}`}
|
|
|
+ onMouseEnter={() => setActive(row.countryCode)}
|
|
|
+ onMouseLeave={() => setActive((cur) => (cur === row.countryCode ? null : cur))}
|
|
|
+ >
|
|
|
+ <td className='world-map__c-name'>
|
|
|
+ <span className='world-map__row-country'>{geo.label}</span>
|
|
|
+ <span className='world-map__row-name'>{row.name}</span>
|
|
|
+ </td>
|
|
|
+ <td className='world-map__c-num world-map__c-close'>{formatIndexClose(row.close)}</td>
|
|
|
+ <td className={`world-map__c-num world-map__c-${dir}`}>{formatChangeVal(row.changeVal)}</td>
|
|
|
+ <td className={`world-map__c-num world-map__c-${dir}`}>{formatFlucRate(row.flucRateBp)}</td>
|
|
|
+ <td className='world-map__c-time'>{row.tradeDate}</td>
|
|
|
+ <td className='world-map__c-chart'>
|
|
|
+ <button
|
|
|
+ type='button'
|
|
|
+ className='world-map__chart-btn'
|
|
|
+ title={`${geo.label} 위치 보기`}
|
|
|
+ aria-label={`${geo.label} ${row.name} 지도에서 보기`}
|
|
|
+ onClick={() => 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' />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ {gridRows.length === 0 && <div className='world-map__empty'>이 지역 지수 데이터가 없습니다.</div>}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</section>
|
|
|
);
|