|
|
@@ -1,7 +1,7 @@
|
|
|
'use client';
|
|
|
|
|
|
import './world-map.scss';
|
|
|
-import { useEffect, useMemo, useState } from 'react';
|
|
|
+import { useMemo, useRef, useState } from 'react';
|
|
|
import { geoNaturalEarth1, geoPath } from 'd3-geo';
|
|
|
import { feature } from 'topojson-client';
|
|
|
import type { Feature, Geometry } from 'geojson';
|
|
|
@@ -19,19 +19,22 @@ import {
|
|
|
} from '@/types/worldIndex';
|
|
|
import TradingViewChart from './TradingViewChart';
|
|
|
|
|
|
-// 등거리(plate carrée) 대신 d3-geo geoNaturalEarth1(곡면) 투영.
|
|
|
-// 국가별 topojson feature 를 GeoJSON 으로 디코드해 육지 path 와 핀 좌표를 "동일 projection" 으로 산출 → 오정렬 원천 차단.
|
|
|
+// 등거리 대신 d3-geo geoNaturalEarth1(곡면) 투영. 국가별 topojson feature 를 GeoJSON 으로 디코드해
|
|
|
+// 육지 path 와 핀 좌표를 "동일 projection" 으로 산출. 팬/줌은 <g> 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.5; // 기본 살짝 확대
|
|
|
|
|
|
type GeoFeature = Feature<Geometry>;
|
|
|
type RegionTab = 'major'|MarketRegion;
|
|
|
+type View = { x: number; y: number; k: number };
|
|
|
|
|
|
-// ── 지도 지오메트리: 정적 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) 제외 — 하단 여백 방지
|
|
|
+ return decoded.features.filter((f) => String(f.id) !== '010'); // 남극 제외
|
|
|
})();
|
|
|
|
|
|
const PROJECTION = geoNaturalEarth1().fitSize([VW, VH], { type: 'FeatureCollection', features: COUNTRIES } as never);
|
|
|
@@ -40,6 +43,11 @@ 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[];
|
|
|
};
|
|
|
@@ -63,75 +71,137 @@ export default function WorldMarketMap({ rows }: Props)
|
|
|
const [region, setRegion] = useState<RegionTab>('major');
|
|
|
const [active, setActive] = useState<string|null>(null);
|
|
|
const [chartRow, setChartRow] = useState<WorldIndexRow|null>(null);
|
|
|
+ const [view, setView] = useState<View>(() => centeredView(DEFAULT_K));
|
|
|
+ const [panning, setPanning] = useState(false);
|
|
|
|
|
|
- // 차트 모달 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]);
|
|
|
+ const svgRef = useRef<SVGSVGElement>(null);
|
|
|
+ const dragRef = useRef<{ px: number; py: number; vx: number; vy: number; moved: boolean }|null>(null);
|
|
|
+ const suppressClickRef = useRef(false);
|
|
|
|
|
|
- // 핀 — 등락 크기에 따라 반경 가변(5~9), projection 으로 좌표 산출
|
|
|
+ // 좌표를 아는 국가만 취급 + 핀 좌표 산출
|
|
|
const pins = useMemo<Pin[]>(() => {
|
|
|
- return geoRows
|
|
|
+ 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(9, 5 + Math.abs(row.flucRateBp) / 90);
|
|
|
+ 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);
|
|
|
- }, [geoRows]);
|
|
|
+ }, [rows]);
|
|
|
|
|
|
- // 지역 탭 — 데이터 있는 탭만 노출(무음 빈 탭 방지). 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];
|
|
|
+ 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);
|
|
|
- }, [geoRows]);
|
|
|
+ }, [pins]);
|
|
|
|
|
|
- // 선택 탭이 데이터 없는 탭이면 첫 탭으로 폴백
|
|
|
const activeRegion: RegionTab = tabs.includes(region) ? region : (tabs[0] ?? 'major');
|
|
|
|
|
|
- // 그리드 = 선택 지역 필터 + 등락률 내림차순
|
|
|
const gridRows = useMemo(() => {
|
|
|
- return geoRows
|
|
|
+ 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);
|
|
|
- }, [geoRows, activeRegion]);
|
|
|
+ }, [pins, activeRegion]);
|
|
|
|
|
|
- // 기준일 = 가장 최근 거래일
|
|
|
const asOf = useMemo(() => {
|
|
|
- return geoRows.reduce<string|null>((max, c) => (max === null || c.tradeDate > max ? c.tradeDate : max), null);
|
|
|
- }, [geoRows]);
|
|
|
+ return pins.reduce<string|null>((max, c) => (max === null || c.row.tradeDate > max ? c.row.tradeDate : max), null);
|
|
|
+ }, [pins]);
|
|
|
+
|
|
|
+ // 지역 탭 클릭 → 그리드 필터 + 지도를 해당 지역으로 이동/확대
|
|
|
+ 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 };
|
|
|
+ });
|
|
|
+ }
|
|
|
|
|
|
- const activePin = pins.find((c) => c.row.countryCode === active) ?? null;
|
|
|
+ function onPointerDown(e: React.PointerEvent<SVGSVGElement>)
|
|
|
+ {
|
|
|
+ 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<SVGSVGElement>)
|
|
|
+ {
|
|
|
+ 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 (
|
|
|
<section className='world-map' aria-label='세계 주요국 증시지수'>
|
|
|
@@ -147,14 +217,18 @@ export default function WorldMarketMap({ rows }: Props)
|
|
|
</div>
|
|
|
|
|
|
<div className='world-map__body'>
|
|
|
- {/* 지도 */}
|
|
|
<div className='world-map__stage'>
|
|
|
<svg
|
|
|
- className='world-map__svg'
|
|
|
+ ref={svgRef}
|
|
|
+ className={`world-map__svg${panning ? ' is-panning' : ''}`}
|
|
|
viewBox={`0 0 ${VW} ${VH}`}
|
|
|
role='img'
|
|
|
- aria-label='세계지도 위 주요국 증시지수 핀'
|
|
|
+ aria-label='세계지도 위 주요국 증시지수 핀 (드래그로 이동, 확대 가능)'
|
|
|
preserveAspectRatio='xMidYMid meet'
|
|
|
+ onPointerDown={onPointerDown}
|
|
|
+ onPointerMove={onPointerMove}
|
|
|
+ onPointerUp={endPan}
|
|
|
+ onPointerLeave={endPan}
|
|
|
>
|
|
|
<defs>
|
|
|
<radialGradient id='wm-ocean' cx='50%' cy='42%' r='75%'>
|
|
|
@@ -163,65 +237,64 @@ export default function WorldMarketMap({ rows }: Props)
|
|
|
</radialGradient>
|
|
|
</defs>
|
|
|
|
|
|
- {/* 대양 배경 */}
|
|
|
+ {/* 대양 (고정 배경 — 팬/줌 영향 없음) */}
|
|
|
<rect x='0' y='0' width={VW} height={VH} rx='16' fill='url(#wm-ocean)' />
|
|
|
|
|
|
- {/* 대륙 (국가별 topojson feature) */}
|
|
|
- <g className='world-map__land'>
|
|
|
- {COUNTRY_PATHS.map((c) => (
|
|
|
- <path key={c.id} d={c.d} />
|
|
|
- ))}
|
|
|
- </g>
|
|
|
+ {/* 팬/줌 그룹 */}
|
|
|
+ <g transform={`translate(${view.x} ${view.y}) scale(${view.k})`}>
|
|
|
+ <g className='world-map__land'>
|
|
|
+ {COUNTRY_PATHS.map((c) => (
|
|
|
+ <path key={c.id} d={c.d} />
|
|
|
+ ))}
|
|
|
+ </g>
|
|
|
+
|
|
|
+ <g className='world-map__pins'>
|
|
|
+ {pins.map((pin) => {
|
|
|
+ const isActive = pin.row.countryCode === active;
|
|
|
+ const leftSide = pin.x > VW * 0.72;
|
|
|
+ const lx = leftSide ? -(pin.radius + 4) : pin.radius + 4;
|
|
|
+ const anchor = leftSide ? 'end' : 'start';
|
|
|
+ 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))}
|
|
|
+ onClick={() => onPinClick(pin.row)}
|
|
|
+ onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && TV_SYMBOL[pin.row.countryCode]) { e.preventDefault(); setChartRow(pin.row); } }}
|
|
|
+ >
|
|
|
+ {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 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))}
|
|
|
- onClick={() => { if (TV_SYMBOL[pin.row.countryCode]) { setChartRow(pin.row); } }}
|
|
|
- onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && TV_SYMBOL[pin.row.countryCode]) { e.preventDefault(); setChartRow(pin.row); } }}
|
|
|
- >
|
|
|
- {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 className='world-map__label' transform={`translate(${lx} 0)`} textAnchor={anchor}>
|
|
|
+ <text className='world-map__label-name' y='-1.5'>{pin.label}</text>
|
|
|
+ <text className={`world-map__label-rate world-map__label-rate--${pin.dir}`} y='9'>{formatFlucRate(pin.row.flucRateBp)}</text>
|
|
|
+ {isActive && <text className='world-map__label-close' y='19'>{formatIndexClose(pin.row.close)}</text>}
|
|
|
+ </g>
|
|
|
+ </g>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </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 className='world-map__zoom'>
|
|
|
+ <button type='button' className='world-map__zoom-btn' aria-label='확대' onClick={() => zoomBy(1.4)}>+</button>
|
|
|
+ <button type='button' className='world-map__zoom-btn' aria-label='축소' onClick={() => zoomBy(1 / 1.4)}>−</button>
|
|
|
+ <button type='button' className='world-map__zoom-btn' aria-label='초기화' title='초기화' onClick={() => setView(centeredView(DEFAULT_K))}>⤢</button>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
|
|
|
- {/* 지수 패널 — 지역 탭 + 데이터 그리드 (지도와 연동) */}
|
|
|
+ {/* 지수 패널 — 지역 탭 + 데이터 그리드 */}
|
|
|
<div className='world-map__panel'>
|
|
|
<div className='world-map__tabs' role='tablist' aria-label='지역'>
|
|
|
{tabs.map((t) => (
|
|
|
@@ -231,7 +304,7 @@ export default function WorldMarketMap({ rows }: Props)
|
|
|
role='tab'
|
|
|
aria-selected={t === activeRegion}
|
|
|
className={`world-map__tab${t === activeRegion ? ' is-active' : ''}`}
|
|
|
- onClick={() => setRegion(t)}
|
|
|
+ onClick={() => selectRegion(t)}
|
|
|
>
|
|
|
{REGION_LABELS[t]}
|
|
|
</button>
|