Răsfoiți Sursa

feat(home): 세계지도 d3-geo 재구축 + 지역 탭/데이터 그리드

- 구운 정적 path(worldLandPath.ts, 50KB) 폐기 → d3-geo geoNaturalEarth1
  곡면 투영 + world-atlas countries-110m topojson 국가별 feature 렌더
- 핀 좌표를 동일 projection 으로 산출(수동 계산 제거, 오정렬 차단)
- 지역 탭(주요국/아시아/유럽/아메리카/중동·아프리카, 데이터 있는 탭만)
- 데이터 그리드(지수명·지수·전일비·등락률·시간·차트) + 지도↔그리드 양방향 하이라이트
- deps: d3-geo, topojson-client, world-atlas (+types)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
KIM-JINO5 2 săptămâni în urmă
părinte
comite
fbff268cba

+ 180 - 72
app/(main)/_components/WorldMarketMap.tsx

@@ -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>
 	);

+ 176 - 30
app/(main)/_components/world-map.scss

@@ -1,15 +1,15 @@
 // ─────────────────────────────────────────────────────────────
-// 메인 세계지도 (WorldMarketMap) — 주요국 증시지수 핀
-// 색은 토큰/반투명 리터럴만 사용(라이트·다크 양립). 등락색은 --price-up/down/flat(상승 적/하락 청)
+// 메인 세계지도 (WorldMarketMap) — 주요국 증시지수 핀 + 지역 탭 + 데이터 그리드
+// d3-geo geoNaturalEarth1 투영 · 국가별 topojson feature 렌더.
+// 색은 토큰/반투명 리터럴만(라이트·다크 양립). 등락색 --price-up/down/flat(상승 적/하락 청).
 // ─────────────────────────────────────────────────────────────
 
 .world-map {
 	// 지도 전용 반투명 톤 — 패널 배경 위에 얹혀 양 테마에서 자연스럽게 블렌딩
-	--az-map-ocean-1: rgba(46, 110, 190, 0.20);
-	--az-map-ocean-2: rgba(46, 110, 190, 0.04);
-	--az-map-land: rgba(140, 158, 180, 0.32);
+	--az-map-ocean-1: rgba(46, 110, 190, 0.18);
+	--az-map-ocean-2: rgba(46, 110, 190, 0.03);
+	--az-map-land: rgba(140, 158, 180, 0.34);
 	--az-map-land-edge: rgba(150, 168, 190, 0.55);
-	--az-map-grid: rgba(130, 145, 170, 0.16);
 
 	margin-bottom: var(--sp-6);
 	padding: var(--sp-4);
@@ -67,11 +67,26 @@
 		font-variant-numeric: tabular-nums;
 	}
 
-	// 무대 — 등거리 크롭 비율(1000×400) 고정, 툴팁 기준 컨테이너
+	// ── 레이아웃: 지도(넓게) + 지수 패널(우측). 좁은 화면은 세로 스택 ──
+	&__body {
+		display: grid;
+		grid-template-columns: minmax(0, 1.5fr) minmax(300px, 1fr);
+		gap: var(--sp-5);
+		align-items: start;
+	}
+
+	@media (max-width: 960px) {
+		&__body {
+			grid-template-columns: 1fr;
+		}
+	}
+
+	// ── 지도 무대 ──
 	&__stage {
 		position: relative;
 		width: 100%;
-		aspect-ratio: 1000 / 400;
+		aspect-ratio: 1000 / 500;
+		min-width: 0;
 	}
 
 	&__svg {
@@ -80,32 +95,16 @@
 		display: block;
 	}
 
-	&__empty {
-		display: flex;
-		align-items: center;
-		justify-content: center;
-		width: 100%;
-		height: 100%;
-		font-size: var(--fs-sm);
-		color: var(--text-muted);
-	}
-
-	// 격자
-	&__grid line {
-		stroke: var(--az-map-grid);
-		stroke-width: 1;
-	}
-
-	// 대륙 (실제 지형 path)
+	// 대륙 (국가별 topojson feature path)
 	&__land path {
 		fill: var(--az-map-land);
 		stroke: var(--az-map-land-edge);
-		stroke-width: 0.6;
+		stroke-width: 0.4;
 		stroke-linejoin: round;
 		vector-effect: non-scaling-stroke;
 	}
 
-	// 핀 — 그룹 color 로 방향색 상속, dot/pulse 는 currentColor
+	// 핀 — 그룹 color 로 방향색 상속, dot/pulse 는 currentColor
 	&__pin {
 		cursor: pointer;
 		outline: none;
@@ -134,7 +133,7 @@
 		pointer-events: none;
 	}
 
-	// 확산 링 (상승/하락 핀) — 중심 기준 확대·소멸 반복
+	// 확산 링 (상승/하락 핀)
 	&__pulse {
 		fill: currentColor;
 		opacity: 0.5;
@@ -144,13 +143,12 @@
 		pointer-events: none;
 	}
 
-	// 활성(hover/focus) 핀 강조 링
 	&__pin.is-active &__pin-dot {
 		stroke: rgba(255, 255, 255, 0.9);
 		stroke-width: 1.5;
 	}
 
-	// 툴팁 — 활성 핀 위에 부유
+	// 툴팁
 	&__tip {
 		position: absolute;
 		transform: translate(-50%, -128%);
@@ -218,6 +216,154 @@
 		font-weight: 400;
 		color: var(--text-muted);
 	}
+
+	// ── 지수 패널 ──
+	&__panel {
+		display: flex;
+		flex-direction: column;
+		min-width: 0;
+	}
+
+	// 지역 탭 (언더라인 스타일)
+	&__tabs {
+		display: flex;
+		flex-wrap: wrap;
+		gap: var(--sp-2);
+		border-bottom: 1px solid var(--border-default);
+		margin-bottom: var(--sp-2);
+	}
+
+	&__tab {
+		padding: var(--sp-3) var(--sp-4);
+		background: transparent;
+		border: none;
+		border-bottom: 2px solid transparent;
+		margin-bottom: -1px;
+		font-size: var(--fs-sm);
+		font-weight: 600;
+		color: var(--text-muted);
+		cursor: pointer;
+		transition: color 0.12s ease, border-color 0.12s ease;
+
+		&:hover {
+			color: var(--text-secondary);
+		}
+
+		&.is-active {
+			color: var(--text-primary);
+			border-bottom-color: var(--text-link);
+		}
+	}
+
+	// 데이터 그리드
+	&__grid-wrap {
+		overflow-x: auto;
+	}
+
+	&__grid {
+		width: 100%;
+		border-collapse: collapse;
+		font-size: var(--fs-sm);
+		font-variant-numeric: tabular-nums;
+
+		th {
+			padding: var(--sp-3);
+			text-align: right;
+			font-size: var(--fs-xs);
+			font-weight: 600;
+			color: var(--text-muted);
+			border-bottom: 1px solid var(--border-default);
+			white-space: nowrap;
+		}
+
+		td {
+			padding: var(--sp-3);
+			border-bottom: 1px solid var(--border-light);
+			white-space: nowrap;
+		}
+	}
+
+	&__row {
+		cursor: default;
+		transition: background 0.1s ease;
+
+		&:hover,
+		&.is-active {
+			background: var(--bg-subtle-hover);
+		}
+	}
+
+	&__c-name {
+		text-align: left;
+	}
+
+	&__c-num {
+		text-align: right;
+	}
+
+	&__c-time {
+		text-align: right;
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+	}
+
+	&__c-chart {
+		text-align: center;
+		width: 1%;
+	}
+
+	&__c-close {
+		color: var(--text-primary);
+		font-weight: 600;
+	}
+
+	&__c-up {
+		color: var(--price-up);
+	}
+
+	&__c-down {
+		color: var(--price-down);
+	}
+
+	&__c-flat {
+		color: var(--price-flat);
+	}
+
+	&__row-country {
+		font-weight: 700;
+		color: var(--text-primary);
+	}
+
+	&__row-name {
+		display: block;
+		font-size: var(--fs-2xs);
+		color: var(--text-muted);
+	}
+
+	&__chart-btn {
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+		padding: var(--sp-2);
+		background: transparent;
+		border: 1px solid var(--border-default);
+		border-radius: var(--radius);
+		color: var(--text-muted);
+		cursor: pointer;
+		transition: color 0.12s ease, border-color 0.12s ease;
+
+		&:hover {
+			color: var(--text-link);
+			border-color: var(--text-link);
+		}
+	}
+
+	&__empty {
+		padding: var(--sp-6);
+		text-align: center;
+		font-size: var(--fs-sm);
+		color: var(--text-muted);
+	}
 }
 
 @keyframes wm-pulse {

Fișier diff suprimat deoarece este prea mare
+ 0 - 1
app/(main)/_components/worldLandPath.ts


+ 82 - 1
package-lock.json

@@ -24,6 +24,7 @@
                 "class-variance-authority": "^0.7.1",
                 "clsx": "^2.1.1",
                 "cmdk": "^1.1.1",
+                "d3-geo": "^3.1.1",
                 "embla-carousel-autoplay": "^8.6.0",
                 "embla-carousel-react": "^8.6.0",
                 "emoji-picker-react": "^4.12.2",
@@ -37,15 +38,19 @@
                 "sass": "^1.83.0",
                 "sonner": "^2.0.7",
                 "tailwind-merge": "^2.6.0",
-                "tailwindcss-animate": "^1.0.7"
+                "tailwindcss-animate": "^1.0.7",
+                "topojson-client": "^3.1.0",
+                "world-atlas": "^2.0.2"
             },
             "devDependencies": {
                 "@ckeditor/ckeditor5-core": "^45.0.0",
                 "@eslint/eslintrc": "^3",
                 "@svgr/webpack": "^8.1.0",
+                "@types/d3-geo": "^3.1.0",
                 "@types/node": "^20",
                 "@types/react": "^19",
                 "@types/react-dom": "^19",
+                "@types/topojson-client": "^3.1.5",
                 "eslint": "^9",
                 "eslint-config-next": "15.1.3",
                 "postcss": "^8",
@@ -13254,6 +13259,16 @@
             "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
             "license": "MIT"
         },
+        "node_modules/@types/d3-geo": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
+            "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@types/geojson": "*"
+            }
+        },
         "node_modules/@types/d3-interpolate": {
             "version": "3.0.4",
             "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
@@ -13316,6 +13331,13 @@
             "dev": true,
             "license": "MIT"
         },
+        "node_modules/@types/geojson": {
+            "version": "7946.0.16",
+            "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+            "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+            "dev": true,
+            "license": "MIT"
+        },
         "node_modules/@types/hast": {
             "version": "3.0.4",
             "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
@@ -13394,6 +13416,27 @@
                 "@types/react": "^19.2.0"
             }
         },
+        "node_modules/@types/topojson-client": {
+            "version": "3.1.5",
+            "resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz",
+            "integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@types/geojson": "*",
+                "@types/topojson-specification": "*"
+            }
+        },
+        "node_modules/@types/topojson-specification": {
+            "version": "1.0.5",
+            "resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz",
+            "integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@types/geojson": "*"
+            }
+        },
         "node_modules/@types/turndown": {
             "version": "5.0.5",
             "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.5.tgz",
@@ -15122,6 +15165,18 @@
                 "node": ">=12"
             }
         },
+        "node_modules/d3-geo": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
+            "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
+            "license": "ISC",
+            "dependencies": {
+                "d3-array": "2.5.0 - 3"
+            },
+            "engines": {
+                "node": ">=12"
+            }
+        },
         "node_modules/d3-interpolate": {
             "version": "3.0.1",
             "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
@@ -20873,6 +20928,26 @@
                 "node": ">=8.0"
             }
         },
+        "node_modules/topojson-client": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
+            "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
+            "license": "ISC",
+            "dependencies": {
+                "commander": "2"
+            },
+            "bin": {
+                "topo2geo": "bin/topo2geo",
+                "topomerge": "bin/topomerge",
+                "topoquantize": "bin/topoquantize"
+            }
+        },
+        "node_modules/topojson-client/node_modules/commander": {
+            "version": "2.20.3",
+            "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+            "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+            "license": "MIT"
+        },
         "node_modules/tough-cookie": {
             "version": "4.1.4",
             "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
@@ -21631,6 +21706,12 @@
                 "node": ">=0.10.0"
             }
         },
+        "node_modules/world-atlas": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/world-atlas/-/world-atlas-2.0.2.tgz",
+            "integrity": "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==",
+            "license": "ISC"
+        },
         "node_modules/ws": {
             "version": "7.5.10",
             "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",

+ 6 - 1
package.json

@@ -25,6 +25,7 @@
         "class-variance-authority": "^0.7.1",
         "clsx": "^2.1.1",
         "cmdk": "^1.1.1",
+        "d3-geo": "^3.1.1",
         "embla-carousel-autoplay": "^8.6.0",
         "embla-carousel-react": "^8.6.0",
         "emoji-picker-react": "^4.12.2",
@@ -38,15 +39,19 @@
         "sass": "^1.83.0",
         "sonner": "^2.0.7",
         "tailwind-merge": "^2.6.0",
-        "tailwindcss-animate": "^1.0.7"
+        "tailwindcss-animate": "^1.0.7",
+        "topojson-client": "^3.1.0",
+        "world-atlas": "^2.0.2"
     },
     "devDependencies": {
         "@ckeditor/ckeditor5-core": "^45.0.0",
         "@eslint/eslintrc": "^3",
         "@svgr/webpack": "^8.1.0",
+        "@types/d3-geo": "^3.1.0",
         "@types/node": "^20",
         "@types/react": "^19",
         "@types/react-dom": "^19",
+        "@types/topojson-client": "^3.1.5",
         "eslint": "^9",
         "eslint-config-next": "15.1.3",
         "postcss": "^8",

+ 32 - 15
types/worldIndex.ts

@@ -16,29 +16,46 @@ export interface WorldIndicesResponse {
 	list: WorldIndexRow[];
 }
 
+// ── 지역(흥국 지역 필터 대응) ──
+// 'major'(주요국)는 지역 교차 큐레이션 탭 — 지리적 region 과 별개로 major 플래그로 표기.
+export type MarketRegion = 'asia'|'europe'|'america'|'mideast';
+
+export const REGION_LABELS: Record<'major'|MarketRegion, string> = {
+	major: '주요국',
+	asia: '아시아',
+	america: '아메리카',
+	europe: '유럽',
+	mideast: '중동·아프리카'
+};
+
+// 지역 탭 노출 순서 (major 우선, 이후 데이터 있는 지역만 렌더)
+export const REGION_ORDER: ('major'|MarketRegion)[] = ['major', 'asia', 'america', 'europe', 'mideast'];
+
 // ── 국가 → 지도 좌표(위경도) + 표시명. 나라당 1개(대표 지수) 기준 ──
 // 프론트가 국가 코드로 세계지도 핀 위치를 정한다(백엔드는 좌표를 보내지 않음 — 응답 경량화).
 export interface ExchangeGeo {
 	lat: number;
 	lng: number;
-	label: string; // 국가 표시명 (한글)
+	label: string;        // 국가 표시명 (한글)
+	region: MarketRegion; // 지리적 지역 탭
+	major?: boolean;      // 주요국 탭 포함 여부
 }
 
 export const EXCHANGE_GEO: Record<string, ExchangeGeo> = {
-	US: { lat: 40.7, lng: -74.0, label: '미국' },
-	KR: { lat: 37.5, lng: 127.0, label: '한국' },
-	JP: { lat: 35.7, lng: 139.7, label: '일본' },
-	CN: { lat: 31.2, lng: 121.5, label: '중국' },
-	HK: { lat: 22.3, lng: 114.2, label: '홍콩' },
-	TW: { lat: 25.0, lng: 121.6, label: '대만' },
-	SG: { lat: 1.35, lng: 103.8, label: '싱가포르' },
-	IN: { lat: 19.1, lng: 72.9, label: '인도' },
-	GB: { lat: 51.5, lng: -0.1, label: '영국' },
-	DE: { lat: 50.1, lng: 8.7, label: '독일' },
-	FR: { lat: 48.9, lng: 2.3, label: '프랑스' },
-	CA: { lat: 43.7, lng: -79.4, label: '캐나다' },
-	BR: { lat: -23.5, lng: -46.6, label: '브라질' },
-	AU: { lat: -33.9, lng: 151.2, label: '호주' }
+	US: { lat: 40.7, lng: -74.0, label: '미국', region: 'america', major: true },
+	KR: { lat: 37.5, lng: 127.0, label: '한국', region: 'asia', major: true },
+	JP: { lat: 35.7, lng: 139.7, label: '일본', region: 'asia', major: true },
+	CN: { lat: 31.2, lng: 121.5, label: '중국', region: 'asia', major: true },
+	HK: { lat: 22.3, lng: 114.2, label: '홍콩', region: 'asia' },
+	TW: { lat: 25.0, lng: 121.6, label: '대만', region: 'asia' },
+	SG: { lat: 1.35, lng: 103.8, label: '싱가포르', region: 'asia' },
+	IN: { lat: 19.1, lng: 72.9, label: '인도', region: 'asia' },
+	AU: { lat: -33.9, lng: 151.2, label: '호주', region: 'asia' },
+	GB: { lat: 51.5, lng: -0.1, label: '영국', region: 'europe', major: true },
+	DE: { lat: 50.1, lng: 8.7, label: '독일', region: 'europe', major: true },
+	FR: { lat: 48.9, lng: 2.3, label: '프랑스', region: 'europe' },
+	CA: { lat: 43.7, lng: -79.4, label: '캐나다', region: 'america' },
+	BR: { lat: -23.5, lng: -46.6, label: '브라질', region: 'america' }
 };
 
 // ── 등락 상태 ──

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff