Просмотр исходного кода

feat(home): 메인 세계지도(주요국 증시 핀) + 관심종목 종목메뉴 한정 + 우측 고정 채팅 도크

- 홈: 등거리 SVG 세계지도 + 등락색 핀·툴팁(WorldMarketMap), GET /api/market/world-indices 병합 데이터. 데이터 있을 때만 노출
- 좌측 관심종목(MarketSidebar)을 /stock 계열에서만 노출(Layout usePathname), 그 외 no-aside. Header 햄버거도 showAside 게이팅
- 우측 채팅 도크(ChatDock): (main)/Layout 상시 마운트로 페이지 전환에도 유지, 접기(레일)+모바일 드로어. MainChat headerSlot/기본값 추가
- /chat→홈 redirect + 채팅 nav 제거 — 도크가 'main' 룸 유일 인스턴스(이중 JoinRoom 방지)
- 그리드 chat 컬럼 추가(aside±/utility± 조합), --az-chat-w 토큰

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
KIM-JINO5 2 недель назад
Родитель
Сommit
e29d10d0cb

+ 188 - 0
app/(main)/_components/WorldMarketMap.tsx

@@ -0,0 +1,188 @@
+'use client';
+
+import './world-map.scss';
+import { useMemo, useState } from 'react';
+import { EXCHANGE_GEO, moveDir, formatFlucRate, formatIndexClose, type WorldIndexRow } from '@/types/worldIndex';
+
+// 등거리(plate carrée) 투영 — viewBox 1000×500 (경도 -180~180, 위도 90~-90)
+const VW = 1000;
+const VH = 500;
+const project = (lat: number, lng: number) => ({
+	x: ((lng + 180) / 360) * VW,
+	y: ((90 - lat) / 180) * VH
+});
+
+// 스타일라이즈드(저폴리) 대륙 실루엣 — 지리적 근사(정밀 지도 아님). viewBox 좌표계 직접 좌표.
+const CONTINENTS: string[] = [
+	// 북미
+	'140,84 210,66 302,70 314,104 288,126 302,158 274,176 250,156 248,196 226,210 200,236 188,222 212,198 214,172 190,156 166,130 150,106',
+	// 그린란드
+	'330,54 366,48 380,74 352,92 332,76',
+	// 남미
+	'300,266 340,258 364,278 380,300 366,336 344,384 320,414 304,420 296,388 300,342 290,300 292,278',
+	// 유럽
+	'470,92 518,84 550,94 560,116 540,134 512,150 496,134 480,142 470,116',
+	// 아프리카
+	'486,176 548,166 588,188 596,236 570,300 542,358 520,350 508,298 500,244 488,206',
+	// 아시아(중동~러시아~중국~인도 벌지)
+	'560,92 634,68 714,70 792,82 858,98 900,120 872,140 852,150 872,178 830,198 800,178 810,214 776,234 748,208 720,180 700,206 686,182 656,168 628,146 600,150 580,128 566,110',
+	// 일본
+	'878,136 896,150 886,172 872,158',
+	// 인도네시아/동남아 도서
+	'756,234 806,230 830,248 800,264 760,258',
+	// 호주
+	'800,316 858,306 928,332 916,362 856,378 812,362 796,336'
+];
+
+// 위/경도 격자선
+const LNG_LINES = [-150, -120, -90, -60, -30, 0, 30, 60, 90, 120, 150];
+const LAT_LINES = [-60, -30, 0, 30, 60];
+
+type Props = {
+	rows: WorldIndexRow[];
+};
+
+type Pin = {
+	row: WorldIndexRow;
+	label: string;
+	x: number;
+	y: number;
+	dir: 'up' | 'down' | 'flat';
+	radius: number;
+};
+
+export default function WorldMarketMap({ rows }: Props)
+{
+	const [active, setActive] = useState<string|null>(null);
+
+	// 좌표를 아는 국가만 핀으로. 등락 크기에 따라 반경 가변(6~10).
+	const pins = useMemo<Pin[]>(() => {
+		return rows
+			.map((row) => {
+				const geo = EXCHANGE_GEO[row.countryCode];
+				if (!geo) {
+					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 };
+			})
+			.filter((c): c is Pin => c !== null);
+	}, [rows]);
+
+	// 기준일 = 핀 중 가장 최근 거래일
+	const asOf = useMemo(() => {
+		return rows.reduce<string|null>((max, c) => (max === null || c.tradeDate > max ? c.tradeDate : max), null);
+	}, [rows]);
+
+	const activePin = pins.find((c) => c.row.countryCode === active) ?? null;
+
+	return (
+		<section className='world-map' aria-label='세계 주요국 증시지수'>
+			<div className='world-map__head'>
+				<h2 className='world-map__title'>세계 증시</h2>
+				<div className='world-map__meta'>
+					<span className='world-map__legend'>
+						<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>}
+				</div>
+			</div>
+
+			<div className='world-map__stage'>
+				{pins.length === 0 ? (
+					<div className='world-map__empty'>표시할 지수 데이터가 없습니다.</div>
+				) : (
+					<svg
+						className='world-map__svg'
+						viewBox={`0 0 ${VW} ${VH}`}
+						role='img'
+						aria-label='세계지도 위 주요국 증시지수 핀'
+						preserveAspectRatio='xMidYMid meet'
+					>
+						<defs>
+							<radialGradient id='wm-ocean' cx='50%' cy='38%' 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 = ((90 - lat) / 180) * VH;
+								return <line key={`lat-${lat}`} x1='0' y1={y} x2={VW} y2={y} />;
+							})}
+						</g>
+
+						{/* 대륙 */}
+						<g className='world-map__land'>
+							{CONTINENTS.map((points, i) => (
+								<polygon key={i} points={points} />
+							))}
+						</g>
+
+						{/* 핀 */}
+						<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))}
+									>
+										{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>
+					</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>
+		</section>
+	);
+}

+ 238 - 0
app/(main)/_components/world-map.scss

@@ -0,0 +1,238 @@
+// ─────────────────────────────────────────────────────────────
+// 메인 세계지도 (WorldMarketMap) — 주요국 증시지수 핀
+// 색은 토큰/반투명 리터럴만 사용(라이트·다크 양립). 등락색은 --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-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);
+	background: var(--bg-elevated);
+	border: 1px solid var(--border-default);
+	border-radius: var(--radius);
+
+	&__head {
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		flex-wrap: wrap;
+		gap: var(--sp-3);
+		margin-bottom: var(--sp-3);
+	}
+
+	&__title {
+		font-size: var(--fs-lg);
+		font-weight: 700;
+		color: var(--text-primary);
+	}
+
+	&__meta {
+		display: flex;
+		align-items: center;
+		gap: var(--sp-4);
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+	}
+
+	&__legend {
+		display: inline-flex;
+		align-items: center;
+		gap: var(--sp-2);
+	}
+
+	&__dot {
+		display: inline-block;
+		width: 8px;
+		height: 8px;
+		border-radius: 50%;
+
+		&--up {
+			background: var(--price-up);
+			margin-left: var(--sp-2);
+		}
+
+		&--down {
+			background: var(--price-down);
+			margin-left: var(--sp-3);
+		}
+	}
+
+	&__asof {
+		font-variant-numeric: tabular-nums;
+	}
+
+	// 무대 — 2:1 등거리 비율 고정, 툴팁 기준 컨테이너
+	&__stage {
+		position: relative;
+		width: 100%;
+		aspect-ratio: 1000 / 500;
+	}
+
+	&__svg {
+		width: 100%;
+		height: 100%;
+		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;
+	}
+
+	// 대륙
+	&__land polygon {
+		fill: var(--az-map-land);
+		stroke: var(--az-map-land-edge);
+		stroke-width: 1;
+		stroke-linejoin: round;
+	}
+
+	// 핀 — 그룹 color 로 방향색을 상속, dot/pulse 는 currentColor
+	&__pin {
+		cursor: pointer;
+		outline: none;
+
+		&--up {
+			color: var(--price-up);
+		}
+
+		&--down {
+			color: var(--price-down);
+		}
+
+		&--flat {
+			color: var(--price-flat);
+		}
+	}
+
+	&__pin-dot {
+		fill: currentColor;
+		filter: drop-shadow(0 0 3px currentColor);
+		transition: r 0.15s ease;
+	}
+
+	&__pin-core {
+		fill: rgba(255, 255, 255, 0.92);
+		pointer-events: none;
+	}
+
+	// 확산 링 (상승/하락 핀) — 중심 기준 확대·소멸 반복
+	&__pulse {
+		fill: currentColor;
+		opacity: 0.5;
+		transform-box: fill-box;
+		transform-origin: center;
+		animation: wm-pulse 2.4s ease-out infinite;
+		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%);
+		min-width: 132px;
+		padding: var(--sp-3) var(--sp-4);
+		background: var(--bg-page);
+		border: 1px solid var(--border-strong);
+		border-radius: var(--radius);
+		box-shadow: 0 var(--sp-2) var(--sp-4) var(--shadow-color);
+		pointer-events: none;
+		z-index: 2;
+	}
+
+	&__tip-head {
+		display: flex;
+		align-items: baseline;
+		gap: var(--sp-2);
+		margin-bottom: var(--sp-2);
+	}
+
+	&__tip-country {
+		font-size: var(--fs-xs);
+		font-weight: 700;
+		color: var(--text-primary);
+	}
+
+	&__tip-name {
+		font-size: var(--fs-2xs);
+		color: var(--text-muted);
+		white-space: nowrap;
+	}
+
+	&__tip-close {
+		font-size: var(--fs-lg);
+		font-weight: 700;
+		font-variant-numeric: tabular-nums;
+		color: var(--text-primary);
+	}
+
+	&__tip-change {
+		display: flex;
+		align-items: baseline;
+		justify-content: space-between;
+		gap: var(--sp-3);
+		margin-top: var(--sp-1);
+		font-size: var(--fs-sm);
+		font-weight: 600;
+		font-variant-numeric: tabular-nums;
+
+		&--up {
+			color: var(--price-up);
+		}
+
+		&--down {
+			color: var(--price-down);
+		}
+
+		&--flat {
+			color: var(--price-flat);
+		}
+	}
+
+	&__tip-exch {
+		font-size: var(--fs-2xs);
+		font-weight: 400;
+		color: var(--text-muted);
+	}
+}
+
+@keyframes wm-pulse {
+	0% {
+		transform: scale(1);
+		opacity: 0.5;
+	}
+	100% {
+		transform: scale(2.6);
+		opacity: 0;
+	}
+}
+
+@media (prefers-reduced-motion: reduce) {
+	.world-map__pulse {
+		animation: none;
+		opacity: 0;
+	}
+}

+ 8 - 5
app/(main)/chat/MainChat.tsx

@@ -1,6 +1,6 @@
 'use client';
 
-import { useState, useRef, useEffect, useCallback, useMemo, type CSSProperties, type KeyboardEvent } from 'react';
+import { useState, useRef, useEffect, useCallback, useMemo, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react';
 import Link from 'next/link';
 import { HubConnectionState } from '@microsoft/signalr';
 import useAuth from '@/hooks/useAuth';
@@ -38,12 +38,14 @@ const formatTime = (dateStr: string) => {
 };
 
 type Props = {
-	initialMessages: ChatMessage[];
-	notice: string|null;
-	slowModeSeconds: number;
+	initialMessages?: ChatMessage[];
+	notice?: string|null;
+	slowModeSeconds?: number;
+	// 헤더 우측(.chat-room__meta)에 주입할 컨트롤 — 채팅 도크의 접기 버튼 등 (없으면 미렌더)
+	headerSlot?: ReactNode;
 };
 
-export default function MainChat({ initialMessages, notice, slowModeSeconds }: Props)
+export default function MainChat({ initialMessages = [], notice = null, slowModeSeconds = 0, headerSlot }: Props)
 {
 	const { isAuthenticated } = useAuth();
 	const { chatConnection, chatConnected } = useSignalRContext();
@@ -269,6 +271,7 @@ export default function MainChat({ initialMessages, notice, slowModeSeconds }: P
 					{connState === 'disconnected' && (
 						<span className='chat-room__state chat-room__state--error' role='status'>연결 끊김 — 새로고침 후 다시 시도해 주세요</span>
 					)}
+					{headerSlot}
 				</div>
 			</header>
 

+ 7 - 22
app/(main)/chat/page.tsx

@@ -1,24 +1,9 @@
-import './style.scss';
-import { Metadata } from 'next';
-import { fetchChatRoomHistory } from '@/lib/api/chat';
-import MainChat from './MainChat';
+import { redirect } from 'next/navigation';
 
-export const metadata: Metadata = {
-	title: '채팅 — 개미투자',
-	description: '개미투자 메인 실시간 채팅 — 시장 이야기를 실시간으로 나누세요'
-};
-
-export default async function ChatPage()
+// 실시간 채팅은 우측 고정 도크(ChatDock)로 상시 제공된다.
+// 기존 /chat 딥링크·북마크는 홈으로 보낸다 — 도크가 'main' 룸의 유일한 MainChat 인스턴스여야
+// JoinRoom/LeaveRoom 이중 참가 충돌이 없다(여기서 별도 MainChat 을 마운트하지 않는 이유).
+export default function ChatPage()
 {
-	// 초기 히스토리는 REST 서버 fetch — 실패해도 페이지는 렌더 (JoinRoom 시 ReceiveHistory 로 보충)
-	const res = await fetchChatRoomHistory('main', 50);
-	const initial = res.success ? res.data : null;
-
-	return (
-		<MainChat
-			initialMessages={initial?.messages ?? []}
-			notice={initial?.notice ?? null}
-			slowModeSeconds={initial?.slowModeSeconds ?? 0}
-		/>
-	);
-}
+	redirect('/');
+}

+ 19 - 2
app/(main)/page.tsx

@@ -1,7 +1,10 @@
 import { fetchJson } from '@/lib/utils/server';
 import type { BannerActiveListResponse, BannerItem } from '@/types/response/banner/active-list';
 import type { LiveChannelListResponse } from '@/types/response/channel/live-list';
+import type { WorldIndexRow } from '@/types/worldIndex';
+import { fetchWorldIndices } from '@/lib/api/worldIndices';
 import HomeBannerCarousel from './_components/HomeBannerCarousel';
+import WorldMarketMap from './_components/WorldMarketMap';
 import LiveChannelGrid from './_components/LiveChannelGrid';
 import LiveChannelEmpty from './_components/LiveChannelEmpty';
 import { FEATURE_CHANNEL } from '@/constants/features';
@@ -33,16 +36,30 @@ async function loadLiveChannels(): Promise<{ channels: LiveChannelListResponse['
 	return { channels: res.data.channels, total: res.data.total };
 }
 
+async function loadWorldIndices(): Promise<WorldIndexRow[]> {
+	// 백엔드 미기동/데이터 없음이어도 홈은 렌더 — 지도는 데이터 있을 때만 표시
+	const res = await fetchWorldIndices();
+
+	if (!res.success || !res.data) {
+		return [];
+	}
+
+	return res.data.list;
+}
+
 export default async function Home() {
 	// 채널 기능 OFF: 라이브 채널 섹션 미노출이므로 조회 생략
-	const [banners, live] = await Promise.all([
+	const [banners, live, worldIndices] = await Promise.all([
 		loadActiveBanners(),
-		FEATURE_CHANNEL ? loadLiveChannels() : Promise.resolve({ channels: [], total: 0 })
+		FEATURE_CHANNEL ? loadLiveChannels() : Promise.resolve({ channels: [], total: 0 }),
+		loadWorldIndices()
 	]);
 
 	return (
 		<div className="home">
 			<div className="home__inner">
+				{worldIndices.length > 0 && <WorldMarketMap rows={worldIndices} />}
+
 				<HomeBannerCarousel items={banners} />
 
 				{FEATURE_CHANNEL && (

+ 100 - 0
app/component/ChatDock.tsx

@@ -0,0 +1,100 @@
+'use client';
+
+import '@/app/(main)/chat/style.scss';
+import '@/app/styles/chat-dock.scss';
+import { useCallback, useEffect, useState } from 'react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faComments, faAnglesRight, faXmark } from '@fortawesome/free-solid-svg-icons';
+import MainChat from '@/app/(main)/chat/MainChat';
+
+const STORAGE_KEY = 'az-chat-collapsed';
+const WIDTH_EXPANDED = '340px';
+const WIDTH_COLLAPSED = '52px';
+
+// 우측 고정 채팅 도크 — (main)/Layout 에 단일 인스턴스로 상시 마운트되어 페이지 전환에도 유지된다(App Router layout 은 자식 라우트 이동에 언마운트 안 됨).
+// MainChat 은 접힘(desktop 레일)/모바일 닫힘 상태에서도 언마운트하지 않고 CSS 로만 숨겨 SignalR 참가(JoinRoom 'main')·메시지·스크롤을 보존한다.
+// → 이 도크가 'main' 룸의 유일한 참가자여야 이중 JoinRoom/LeaveRoom 충돌이 없다 (그래서 /chat 페이지는 별도 MainChat 을 마운트하지 않는다).
+export default function ChatDock()
+{
+	// 데스크톱 접힘(레일) — localStorage 영속. 기본 펼침.
+	const [collapsed, setCollapsed] = useState(false);
+	// 모바일 드로어 열림 — 비영속, 기본 닫힘.
+	const [drawerOpen, setDrawerOpen] = useState(false);
+
+	// 최초 mount 시 localStorage 에서 collapsed 복원
+	useEffect(() => {
+		try {
+			const saved = window.localStorage.getItem(STORAGE_KEY);
+			if (saved === 'true') {
+				setCollapsed(true);
+			}
+		} catch {}
+	}, []);
+
+	// collapsed 변경 시 html 루트 CSS 변수 동기화 — desktop grid 의 chat 컬럼 폭(모바일은 fixed 라 무영향)
+	useEffect(() => {
+		const root = document.documentElement;
+		root.style.setProperty('--antooza-chat-width', collapsed ? WIDTH_COLLAPSED : WIDTH_EXPANDED);
+		try {
+			window.localStorage.setItem(STORAGE_KEY, String(collapsed));
+		} catch {}
+		return () => {
+			root.style.removeProperty('--antooza-chat-width');
+		};
+	}, [collapsed]);
+
+	const collapse = useCallback(() => setCollapsed(true), []);
+	const expand = useCallback(() => setCollapsed(false), []);
+	const openDrawer = useCallback(() => setDrawerOpen(true), []);
+	const closeDrawer = useCallback(() => setDrawerOpen(false), []);
+
+	// MainChat 헤더(.chat-room__meta)에 주입할 컨트롤 — desktop=접기, mobile=드로어 닫기 (CSS 로 상호 배타 노출)
+	const headerControls = (
+		<>
+			<button type='button' className='chat-dock__ctrl chat-dock__ctrl--collapse' onClick={collapse} aria-label='채팅 접기' title='채팅 접기'>
+				<FontAwesomeIcon icon={faAnglesRight} />
+			</button>
+			<button type='button' className='chat-dock__ctrl chat-dock__ctrl--close' onClick={closeDrawer} aria-label='채팅 닫기' title='채팅 닫기'>
+				<FontAwesomeIcon icon={faXmark} />
+			</button>
+		</>
+	);
+
+	return (
+		<>
+			<aside
+				className={`chat-dock${collapsed ? ' chat-dock--collapsed' : ''}${drawerOpen ? ' chat-dock--open' : ''}`}
+				aria-label='실시간 채팅'
+			>
+				{/* 접힘 레일 (desktop 전용) — 펼치기 */}
+				<button type='button' className='chat-dock__rail' onClick={expand} aria-label='채팅 펼치기' title='채팅 펼치기'>
+					<FontAwesomeIcon icon={faComments} />
+					<span className='chat-dock__rail-label'>채팅</span>
+				</button>
+
+				{/* 채팅 본체 — 상시 마운트, 접힘/닫힘 시 CSS 로만 숨김 */}
+				<div className='chat-dock__inner'>
+					<MainChat headerSlot={headerControls} />
+				</div>
+			</aside>
+
+			{/* 모바일 플로팅 토글 */}
+			<button
+				type='button'
+				className='chat-dock__fab'
+				onClick={openDrawer}
+				aria-label='실시간 채팅 열기'
+				aria-expanded={drawerOpen}
+			>
+				<FontAwesomeIcon icon={faComments} />
+			</button>
+
+			{/* 모바일 오버레이 */}
+			<div
+				className={`chat-dock__overlay${drawerOpen ? ' chat-dock__overlay--visible' : ''}`}
+				onClick={closeDrawer}
+				aria-hidden='true'
+			/>
+		</>
+	);
+}

+ 9 - 6
app/component/Header.tsx

@@ -31,7 +31,6 @@ const baseNavItems = [
 	{ href: '/board/briefing', label: '장전브리핑' },
 	{ href: '/board/proof', label: '수익인증' },
 	{ href: '/feed/all', label: '피드' },
-	{ href: '/chat', label: '채팅' },
 	{ href: '/store', label: '상점' },
 	{ href: '/attendance', label: '출석부' },
 	{ href: '/board/notice', label: '고객지원' }
@@ -44,9 +43,11 @@ const SCROLL_AMOUNT = 120;
 type Props = {
 	sidebarOpen: boolean;
 	onToggle: () => void;
+	// 좌측 사이드바 노출 여부 — false 면 모바일 햄버거(사이드바 토글)도 숨김
+	showAside?: boolean;
 };
 
-export default function Header({ sidebarOpen, onToggle }: Props)
+export default function Header({ sidebarOpen, onToggle, showAside = true }: Props)
 {
 	const { isAuthenticated } = useAuth();
 	const { totalCount: cartCount } = useCart();
@@ -74,10 +75,12 @@ export default function Header({ sidebarOpen, onToggle }: Props)
 
 			{/* 1줄: 로고 + 우측 아이콘 (PC에서는 전체 내비) */}
 			<div className={Styles.headerRow1}>
-				{/* 햄버거 — 좌측 사이드바(채널 or 관심종목 자리표시) 슬라이드 토글 (모바일 전용) */}
-				<button type='button' className={Styles.hamburger} onClick={onToggle} aria-label='메뉴' aria-expanded={sidebarOpen}>
-					<FontAwesomeIcon icon={sidebarOpen ? faXmark : faBars} />
-				</button>
+				{/* 햄버거 — 좌측 사이드바(채널 or 관심종목) 슬라이드 토글 (모바일 전용). 사이드바 없는 페이지에선 미표시 */}
+				{showAside && (
+					<button type='button' className={Styles.hamburger} onClick={onToggle} aria-label='메뉴' aria-expanded={sidebarOpen}>
+						<FontAwesomeIcon icon={sidebarOpen ? faXmark : faBars} />
+					</button>
+				)}
 
 				<Link href='/' className={Styles.logo} aria-label="개미투자 홈">
 					<Logo size="sm" />

+ 24 - 10
app/component/Layout.tsx

@@ -1,11 +1,13 @@
 'use client';
 
 import { useState, useCallback } from 'react';
+import { usePathname } from 'next/navigation';
 import Styles from '../styles/layout.module.scss';
 import Header from './Header';
 import Footer from './Footer'
 import ChannelSidebar from '@/app/component/channel/ChannelSidebar';
 import MarketSidebar from '@/app/component/MarketSidebar';
+import ChatDock from '@/app/component/ChatDock';
 import { ChannelListItem } from '@/types/channel';
 import { FEATURE_CHANNEL } from '@/constants/features';
 
@@ -18,6 +20,7 @@ type Props = {
 
 export default function Layout({ children, initialChannels, utility }: Props) {
 	const [sidebarOpen, setSidebarOpen] = useState(false);
+	const pathname = usePathname();
 
 	const toggleSidebar = useCallback(() => {
 		setSidebarOpen((prev) => !prev);
@@ -27,26 +30,34 @@ export default function Layout({ children, initialChannels, utility }: Props) {
 		setSidebarOpen(false);
 	}, []);
 
+	// 좌측 사이드바 노출 규칙:
+	// - 채널 기능 ON → 항상(채널 목록은 전역 내비)
+	// - 채널 기능 OFF → 종목(/stock) 계열에서만(관심종목). 그 외 페이지는 no-aside 로 본문 확장
+	const isStockRoute = pathname === '/stock' || pathname.startsWith('/stock/');
+	const showAside = FEATURE_CHANNEL || isStockRoute;
+
 	return (
 		<>
 			{/* 접근성: 스킵 링크 — Tab 첫 포커스 시 노출 */}
 			<a href='#main' className={Styles.skipLink}>본문 바로가기</a>
 
-			<div id='container' className={`${Styles.container}${sidebarOpen ? ` ${Styles.sidebarOpen}` : ''}${utility ? ` ${Styles.hasUtility}` : ''}`}>
+			<div id='container' className={`${Styles.container}${sidebarOpen ? ` ${Styles.sidebarOpen}` : ''}${!showAside ? ` ${Styles.noAside}` : ''}${utility ? ` ${Styles.hasUtility}` : ''}`}>
 
-				<Header sidebarOpen={sidebarOpen} onToggle={toggleSidebar} />
+				<Header sidebarOpen={sidebarOpen} onToggle={toggleSidebar} showAside={showAside} />
 
 				{/* 모바일 오버레이 */}
 				<div className={Styles.overlay} onClick={closeOverlay} />
 
-				{/* 좌측 사이드바 — 채널 기능 ON: 채널 목록 / OFF: 관심종목 자리표시 (MarketSidebar) */}
-				<aside id='aside' className={Styles.aside} aria-label='사이드바'>
-					{FEATURE_CHANNEL ? (
-						<ChannelSidebar initialChannels={initialChannels} />
-					) : (
-						<MarketSidebar />
-					)}
-				</aside>
+				{/* 좌측 사이드바 — 채널 기능 ON: 채널 목록 / OFF: 관심종목(종목 메뉴에서만) */}
+				{showAside && (
+					<aside id='aside' className={Styles.aside} aria-label='사이드바'>
+						{FEATURE_CHANNEL ? (
+							<ChannelSidebar initialChannels={initialChannels} />
+						) : (
+							<MarketSidebar />
+						)}
+					</aside>
+				)}
 
 				{/* 메인 내용 */}
 				<main id='main' className={`${Styles.main} relative`}>
@@ -60,6 +71,9 @@ export default function Layout({ children, initialChannels, utility }: Props) {
 					</aside>
 				)}
 
+				{/* 우측 고정 채팅 도크 — 페이지 전환에도 유지(상시 마운트). desktop=컬럼(접기), mobile=드로어 */}
+				<ChatDock />
+
 				{/* 하단 */}
 				<Footer />
 

+ 208 - 0
app/styles/chat-dock.scss

@@ -0,0 +1,208 @@
+// ─────────────────────────────────────────────────────────────
+// 우측 고정 채팅 도크 (ChatDock) — 색/치수는 토큰 변수만 사용(하드코딩 금지)
+// mobile(<1124px): 우측 fixed 드로어 + 플로팅 버튼 / desktop(≥1124px): grid chat 컬럼(접기=레일)
+// #chat-room 본체 스타일은 (main)/chat/style.scss 에서 로드(도크가 함께 import)
+// ─────────────────────────────────────────────────────────────
+
+.chat-dock {
+	display: flex;
+	flex-direction: column;
+	overflow: hidden;
+	background: var(--bg-page);
+
+	// ── mobile 기본 = 우측 슬라이드 드로어(fixed) ──
+	position: fixed;
+	top: var(--header-h, 56px);
+	right: 0;
+	width: min(360px, 90vw);
+	height: calc(100vh - var(--header-h, 56px));
+	height: calc(100dvh - var(--header-h, 56px));
+	z-index: 1100;
+	transform: translateX(100%);
+	transition: transform 0.3s ease;
+	border-left: 1px solid var(--border-default);
+	box-shadow: none;
+
+	&--open {
+		transform: translateX(0);
+		box-shadow: -4px 0 12px var(--shadow-color);
+	}
+
+	@media (prefers-reduced-motion: reduce) {
+		transition: none;
+	}
+
+	// ── desktop = grid chat 컬럼(흐름 안) ──
+	@media (min-width: 1124px) {
+		position: relative;
+		grid-area: chat;
+		top: auto;
+		right: auto;
+		width: auto;
+		height: auto;
+		z-index: auto;
+		transform: none;
+		transition: none;
+		box-shadow: none;
+	}
+
+	// 본체 래퍼 — #chat-room(height:100%)을 채우도록 flex 채움
+	&__inner {
+		display: flex;
+		flex: 1;
+		min-height: 0;
+	}
+
+	// 도크 안 #chat-room 은 좁은 폭에 맞춰 패딩·타이포 축소
+	#chat-room {
+		flex: 1;
+		min-width: 0;
+		padding: var(--sp-3);
+		gap: var(--sp-2);
+
+		.chat-room__head h1 {
+			font-size: var(--fs-lg);
+		}
+	}
+
+	// 헤더 주입 컨트롤(접기/닫기)
+	&__ctrl {
+		display: none;
+		align-items: center;
+		justify-content: center;
+		width: var(--sp-6);
+		height: var(--sp-6);
+		border: none;
+		background: transparent;
+		color: var(--text-secondary);
+		font-size: var(--fs-lg);
+		cursor: pointer;
+		border-radius: var(--radius);
+
+		&:hover {
+			background: var(--bg-icon);
+			color: var(--text-primary);
+		}
+
+		// desktop: 접기만 노출 / mobile: 닫기만 노출
+		&--collapse {
+			@media (min-width: 1124px) {
+				display: inline-flex;
+			}
+		}
+
+		&--close {
+			@media (max-width: 1123.98px) {
+				display: inline-flex;
+			}
+		}
+	}
+
+	// ── 접힘 레일(desktop 전용) — 기본 숨김, --collapsed 에서만 노출 ──
+	&__rail {
+		display: none;
+		flex-direction: column;
+		align-items: center;
+		gap: var(--sp-3);
+		width: 100%;
+		height: 100%;
+		padding: var(--sp-4) 0;
+		border: none;
+		background: transparent;
+		color: var(--text-secondary);
+		cursor: pointer;
+
+		&:hover {
+			color: var(--az-accent);
+		}
+
+		svg {
+			font-size: var(--fs-xl);
+		}
+	}
+
+	&__rail-label {
+		writing-mode: vertical-rl;
+		text-orientation: upright;
+		font-size: var(--fs-xs);
+		letter-spacing: 0.1em;
+	}
+
+	// desktop 접힘: 본체 숨기고 레일 노출
+	@media (min-width: 1124px) {
+		&--collapsed {
+			.chat-dock__inner {
+				display: none;
+			}
+
+			.chat-dock__rail {
+				display: flex;
+			}
+		}
+	}
+
+	// mobile 에서는 접기(레일) 개념 없음 — 본체 항상 표시
+	@media (max-width: 1123.98px) {
+		&__rail {
+			display: none;
+		}
+
+		.chat-dock__inner {
+			display: flex;
+		}
+	}
+}
+
+// ── 모바일 플로팅 토글 버튼(FAB) — desktop 미표시 ──
+.chat-dock__fab {
+	display: none;
+	position: fixed;
+	right: 16px;
+	bottom: 16px;
+	width: 52px;
+	height: 52px;
+	border: none;
+	border-radius: 50%;
+	background: var(--az-accent);
+	color: var(--btn-primary-text);
+	font-size: var(--fs-xl);
+	cursor: pointer;
+	z-index: 1000;
+	box-shadow: 0 var(--sp-1) var(--sp-4) var(--shadow-color);
+
+	@media (max-width: 1123.98px) {
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+	}
+
+	&:hover {
+		background: var(--az-accent-hover);
+	}
+}
+
+// 드로어 열림 시 FAB 숨김(오버레이와 중첩 방지)
+.chat-dock--open ~ .chat-dock__fab {
+	display: none;
+}
+
+// ── 모바일 오버레이 ──
+.chat-dock__overlay {
+	display: none;
+
+	@media (max-width: 1123.98px) {
+		position: fixed;
+		top: var(--header-h, 56px);
+		left: 0;
+		right: 0;
+		bottom: 0;
+		background: var(--overlay-color);
+		z-index: 1050;
+	}
+
+	&--visible {
+		@media (max-width: 1123.98px) {
+			display: block;
+		}
+	}
+}

+ 11 - 11
app/styles/layout.module.scss

@@ -356,10 +356,10 @@
 	height: 100dvh;
 	grid-template-rows: auto 1fr auto;
 
-	// desktop 이상: 좌 사이드바 + 메인 (2열 기본)
+	// desktop 이상: 좌 사이드바 + 메인 + 우 채팅 도크 (3열 기본 — 채팅은 상시)
 	@media (min-width: 1124px) {
-		grid-template-areas: "header header" "aside main" "aside footer";
-		grid-template-columns: var(--antooza-sidebar-width, var(--az-sidebar-w, 220px)) minmax(0, 1fr);
+		grid-template-areas: "header header header" "aside main chat" "aside footer footer";
+		grid-template-columns: var(--antooza-sidebar-width, var(--az-sidebar-w, 220px)) minmax(0, 1fr) var(--antooza-chat-width, var(--az-chat-w, 340px));
 		transition: grid-template-columns 0.2s ease;
 	}
 
@@ -373,25 +373,25 @@
 		grid-template-columns: minmax(0, 1fr);
 	}
 
-	// 우 유틸 슬롯 제공 페이지: 3열 (desktop 이상에서만)
+	// 우 유틸 슬롯 제공 페이지: 4열 (aside + main + utility + chat, desktop 이상)
 	&.hasUtility {
 		@media (min-width: 1124px) {
-			grid-template-areas: "header header header" "aside main utility" "aside footer footer";
-			grid-template-columns: var(--antooza-sidebar-width, var(--az-sidebar-w, 220px)) minmax(0, 1fr) var(--az-utility-w, 280px);
+			grid-template-areas: "header header header header" "aside main utility chat" "aside footer footer footer";
+			grid-template-columns: var(--antooza-sidebar-width, var(--az-sidebar-w, 220px)) minmax(0, 1fr) var(--az-utility-w, 280px) var(--antooza-chat-width, var(--az-chat-w, 340px));
 		}
 	}
 
-	// 사이드바 없는 변형 (보존 — 필요 페이지에서 재사용)
+	// 사이드바 없는 변형 (관심종목 미노출 페이지) — 메인 + 우 채팅
 	&.noAside {
 		@media (min-width: 1124px) {
-			grid-template-areas: "header" "main" "footer";
-			grid-template-columns: minmax(0, 1fr);
+			grid-template-areas: "header header" "main chat" "footer footer";
+			grid-template-columns: minmax(0, 1fr) var(--antooza-chat-width, var(--az-chat-w, 340px));
 		}
 
 		&.hasUtility {
 			@media (min-width: 1124px) {
-				grid-template-areas: "header header" "main utility" "footer footer";
-				grid-template-columns: minmax(0, 1fr) var(--az-utility-w, 280px);
+				grid-template-areas: "header header header" "main utility chat" "footer footer footer";
+				grid-template-columns: minmax(0, 1fr) var(--az-utility-w, 280px) var(--antooza-chat-width, var(--az-chat-w, 340px));
 			}
 		}
 	}

+ 1 - 0
app/styles/tokens/_layout.scss

@@ -10,6 +10,7 @@
 	--ticker-h: 32px;
 	--az-sidebar-w: 220px; // 좌 사이드바 기본 폭 — 런타임 토글(--antooza-sidebar-width)이 우선
 	--az-utility-w: 280px; // 우 유틸 슬롯 폭
+	--az-chat-w: 340px; // 우 고정 채팅 도크 폭 — 런타임 토글(--antooza-chat-width)이 우선(펼침 340 / 접힘 레일 52)
 
 	// 간격 스케일 (2/4/8/12/16/24/32)
 	--sp-1: 2px;

+ 16 - 0
lib/api/worldIndices.ts

@@ -0,0 +1,16 @@
+'use server';
+
+import { ResultDto } from '@/types/response/common';
+import { WorldIndicesResponse } from '@/types/worldIndex';
+import { fetchJson } from '@/lib/utils/server';
+
+const JSON_HEADERS = { 'Accept': 'application/json' } as const;
+
+// 세계 주요국 증시지수 — 국내(코스피) + 해외 스냅샷 병합. 메인 세계지도 핀 (익명 열람)
+export async function fetchWorldIndices(): Promise<ResultDto<WorldIndicesResponse>>
+{
+	return await fetchJson<WorldIndicesResponse>('/api/market/world-indices', {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}

+ 67 - 0
types/worldIndex.ts

@@ -0,0 +1,67 @@
+// 세계 주요국 증시지수 도메인 타입 — Backend Application/Features/Api/Stocks/GetWorldIndices/Response.cs 대응
+// 직렬화 주의: 필드명 camelCase. flucRateBp = 등락률 basis point(%×100). enum 없음(전부 primitive).
+
+// ── GET /api/market/world-indices ──
+export interface WorldIndexRow {
+	countryCode: string; // ISO2 대문자 — EXCHANGE_GEO 좌표맵 키
+	name: string;        // 지수명 (예: "코스피", "S&P 500")
+	exchangeName: string;
+	close: number;
+	changeVal: number;
+	flucRateBp: number;  // %×100 (예: -70 = -0.70%)
+	tradeDate: string;   // yyyy-MM-dd
+}
+
+export interface WorldIndicesResponse {
+	list: WorldIndexRow[];
+}
+
+// ── 국가 → 지도 좌표(위경도) + 표시명. 나라당 1개(대표 지수) 기준 ──
+// 프론트가 국가 코드로 세계지도 핀 위치를 정한다(백엔드는 좌표를 보내지 않음 — 응답 경량화).
+export interface ExchangeGeo {
+	lat: number;
+	lng: number;
+	label: string; // 국가 표시명 (한글)
+}
+
+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: '호주' }
+};
+
+// ── 등락 상태 ──
+export type MoveDir = 'up' | 'down' | 'flat';
+
+export function moveDir(flucRateBp: number): MoveDir {
+	if (flucRateBp > 0) {
+		return 'up';
+	}
+	if (flucRateBp < 0) {
+		return 'down';
+	}
+	return 'flat';
+}
+
+// 등락률 Bp → "+1.45%" / "-0.70%" / "0.00%"
+export function formatFlucRate(flucRateBp: number): string {
+	const pct = flucRateBp / 100;
+	const sign = flucRateBp > 0 ? '+' : '';
+	return `${sign}${pct.toFixed(2)}%`;
+}
+
+// 종가 표시 — 천단위 콤마 + 소수 2자리
+export function formatIndexClose(close: number): string {
+	return close.toLocaleString('ko-KR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+}