'use client'; import './world-map.scss'; import { useEffect, useMemo, useState } from 'react'; 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, TV_SYMBOL } from '@/types/worldIndex'; import TradingViewChart from './TradingViewChart'; // 등거리(plate carrée) 대신 d3-geo geoNaturalEarth1(곡면) 투영. // 국가별 topojson feature 를 GeoJSON 으로 디코드해 육지 path 와 핀 좌표를 "동일 projection" 으로 산출 → 오정렬 원천 차단. // (Wave1 — .claude/plan/global-market-research-pages.md) const VW = 1000; const VH = 500; type GeoFeature = Feature; 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[]; }; type Pin = { row: WorldIndexRow; label: string; x: number; y: number; 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('major'); const [active, setActive] = useState(null); const [chartRow, setChartRow] = useState(null); // 차트 모달 Esc 닫기 useEffect(() => { if (!chartRow) { return; } const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { setChartRow(null); } }; window.addEventListener('keydown', onKey); return () => { window.removeEventListener('keydown', onKey); }; }, [chartRow]); // 좌표를 아는 국가만 취급 const geoRows = useMemo(() => { return rows.filter((r) => EXCHANGE_GEO[r.countryCode]); }, [rows]); // 핀 — 등락 크기에 따라 반경 가변(5~9), projection 으로 좌표 산출 const pins = useMemo(() => { return geoRows .map((row) => { const geo = EXCHANGE_GEO[row.countryCode]; const xy = PROJECTION([geo.lng, geo.lat]); if (!xy) { return null; } 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); }, [geoRows]); // 지역 탭 — 데이터 있는 탭만 노출(무음 빈 탭 방지). major = 주요국 큐레이션. const tabs = useMemo(() => { const counts: Record = { 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 geoRows.reduce((max, c) => (max === null || c.tradeDate > max ? c.tradeDate : max), null); }, [geoRows]); const activePin = pins.find((c) => c.row.countryCode === active) ?? null; return (

세계 증시