'use client'; import './world-map.scss'; import { useEffect, useMemo, useRef, 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'; // 등거리 대신 d3-geo geoNaturalEarth1(곡면) 투영. 국가별 topojson feature 를 GeoJSON 으로 디코드해 // 육지 path 와 핀 좌표를 "동일 projection" 으로 산출. 팬/줌은 transform 으로 처리(드래그 이동·확대). // (Wave1 — .claude/plan/global-market-research-pages.md) const VW = 1000; const VH = 500; const MIN_K = 1; const MAX_K = 6; const DEFAULT_K = 1; // 기본: 전체 세계지도 표시 (확대는 휠/드래그/지역탭) const PIN_H = 15; // 핀 막대 높이 — 머리를 지점 위로 띄워 겹침 완화(map-pin) type GeoFeature = Feature; type RegionTab = 'major'|MarketRegion; type View = { x: number; y: number; k: number }; 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'); // 남극 제외 })(); 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); const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); // viewBox 중심 기준으로 k 배 확대한 초기 view const centeredView = (k: number): View => ({ x: (VW / 2) * (1 - k), y: (VH / 2) * (1 - k), k }); 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); const [view, setView] = useState(() => centeredView(DEFAULT_K)); const [panning, setPanning] = useState(false); const svgRef = useRef(null); const dragRef = useRef<{ px: number; py: number; vx: number; vy: number; moved: boolean }|null>(null); const suppressClickRef = useRef(false); // 마우스 휠 확대/축소 (커서 위치 기준). React onWheel 은 passive 라 네이티브 리스너로 등록(preventDefault 필요). useEffect(() => { const svg = svgRef.current; if (!svg) { return; } const onWheel = (e: WheelEvent) => { e.preventDefault(); const rect = svg.getBoundingClientRect(); if (rect.width === 0) { return; } const cx = (e.clientX - rect.left) * (VW / rect.width); const cy = (e.clientY - rect.top) * (VH / rect.height); const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12; setView((v) => { const k = clamp(v.k * factor, MIN_K, MAX_K); return { x: cx - (cx - v.x) * (k / v.k), y: cy - (cy - v.y) * (k / v.k), k }; }); }; svg.addEventListener('wheel', onWheel, { passive: false }); return () => { svg.removeEventListener('wheel', onWheel); }; }, []); // 좌표를 아는 국가만 취급 + 핀 좌표 산출 const pins = useMemo(() => { return rows .map((row) => { const geo = EXCHANGE_GEO[row.countryCode]; if (!geo) { return null; } const xy = PROJECTION([geo.lng, geo.lat]); if (!xy) { return null; } const radius = Math.min(8, 4.5 + Math.abs(row.flucRateBp) / 110); return { row, label: geo.label, x: xy[0], y: xy[1], dir: moveDir(row.flucRateBp), radius }; }) .filter((c): c is Pin => c !== null); }, [rows]); // 지역 탭 (데이터 있는 탭만) const tabs = useMemo(() => { const counts: Record = { major: 0, asia: 0, europe: 0, america: 0, mideast: 0 }; pins.forEach((p) => { const geo = EXCHANGE_GEO[p.row.countryCode]; counts[geo.region] += 1; if (geo.major) { counts.major += 1; } }); return REGION_ORDER.filter((t) => counts[t] > 0); }, [pins]); const activeRegion: RegionTab = tabs.includes(region) ? region : (tabs[0] ?? 'major'); const gridRows = useMemo(() => { return pins .map((p) => p.row) .filter((r) => { const geo = EXCHANGE_GEO[r.countryCode]; return activeRegion === 'major' ? geo.major === true : geo.region === activeRegion; }) .sort((a, b) => b.flucRateBp - a.flucRateBp); }, [pins, activeRegion]); const asOf = useMemo(() => { return pins.reduce((max, c) => (max === null || c.row.tradeDate > max ? c.row.tradeDate : max), null); }, [pins]); // 라벨 겹침 자동 회피 — 화면좌표(변환 후)에서 큰 등락 순으로 배치, 이미 놓인 라벨과 겹치면 숨김. // 확대(k↑)하면 핀 간격이 벌어져 겹침이 풀리므로 더 많은 라벨이 나타난다. (핀 머리/막대는 항상 표시) const labeledSet = useMemo(() => { const ordered = [...pins].sort((a, b) => Math.abs(b.row.flucRateBp) - Math.abs(a.row.flucRateBp)); const placed: { x1: number; y1: number; x2: number; y2: number }[] = []; const show = new Set(); for (const pin of ordered) { const sx = view.x + pin.x * view.k; const sy = view.y + pin.y * view.k; if (sx < -24 || sx > VW + 24 || sy < -24 || sy > VH + 24) { continue; } const hy = sy - PIN_H; const left = sx > VW * 0.72; const w = Math.max(pin.label.length * 11.5, 46) + 6; const x1 = left ? sx - (pin.radius + 4) - w : sx + (pin.radius + 4); const box = { x1, y1: hy - 13, x2: x1 + w, y2: hy + 12 }; const hit = placed.some((p) => !(box.x2 < p.x1 || box.x1 > p.x2 || box.y2 < p.y1 || box.y1 > p.y2)); if (!hit) { placed.push(box); show.add(pin.row.countryCode); } } return show; }, [pins, view]); // 지역 탭 클릭 → 그리드 필터 + 지도를 해당 지역으로 이동/확대 function selectRegion(t: RegionTab) { setRegion(t); if (t === 'major') { setView(centeredView(DEFAULT_K)); return; } const regionPins = pins.filter((p) => EXCHANGE_GEO[p.row.countryCode].region === t); if (regionPins.length === 0) { return; } const xs = regionPins.map((p) => p.x); const ys = regionPins.map((p) => p.y); const cx = (Math.min(...xs) + Math.max(...xs)) / 2; const cy = (Math.min(...ys) + Math.max(...ys)) / 2; const spreadX = Math.max(Math.max(...xs) - Math.min(...xs), 70); const spreadY = Math.max(Math.max(...ys) - Math.min(...ys), 70); const k = clamp(Math.min(VW / (spreadX * 2.2), VH / (spreadY * 2.2)), 1.6, 4); setView({ x: VW / 2 - cx * k, y: VH / 2 - cy * k, k }); } function zoomBy(factor: number) { setView((v) => { const k = clamp(v.k * factor, MIN_K, MAX_K); const cx = VW / 2; const cy = VH / 2; return { x: cx - (cx - v.x) * (k / v.k), y: cy - (cy - v.y) * (k / v.k), k }; }); } function onPointerDown(e: React.PointerEvent) { if (e.button !== 0) { return; } dragRef.current = { px: e.clientX, py: e.clientY, vx: view.x, vy: view.y, moved: false }; setPanning(true); } function onPointerMove(e: React.PointerEvent) { const d = dragRef.current; if (!d) { return; } const rect = svgRef.current?.getBoundingClientRect(); const factor = rect && rect.width > 0 ? VW / rect.width : 1; const dx = (e.clientX - d.px) * factor; const dy = (e.clientY - d.py) * factor; if (Math.abs(e.clientX - d.px) + Math.abs(e.clientY - d.py) > 3) { d.moved = true; } setView((v) => ({ ...v, x: d.vx + dx, y: d.vy + dy })); } function endPan() { suppressClickRef.current = dragRef.current?.moved ?? false; dragRef.current = null; setPanning(false); } function onPinClick(row: WorldIndexRow) { if (suppressClickRef.current) { suppressClickRef.current = false; return; } if (TV_SYMBOL[row.countryCode]) { setChartRow(row); } } return (

세계 증시