/** * 멀티차트 WebSocket 연결 상태 표시 * - 클릭 시 연결 목록 및 수신 상태 팝업 * - MultiChartMarketDataProvider 내부에서 사용 */ import React from 'react'; import { Box, Popover, LinearProgress, Typography, Tooltip, Button } from '@mui/material'; import { useMultiChartMarketData } from '../../contexts/MultiChartMarketDataContext'; import type { ConnectionStatus } from '../../hooks/useWebSocketConnectionStatus'; const WS_STATUS_CONFIG: Record = { connected: { label: '실시간 연결됨', color: 'success.main', bgColor: 'success.main' }, connecting: { label: '연결 중...', color: 'warning.main', bgColor: 'warning.main' }, disconnected: { label: '연결 끊김', color: 'text.secondary', bgColor: 'grey.400' }, error: { label: '연결 오류', color: 'error.main', bgColor: 'error.main' }, }; export const MultiChartWebSocketStatus: React.FC = () => { const ctx = useMultiChartMarketData(); const status = ctx?.status ?? 'disconnected'; const connectionList = ctx?.connectionList ?? []; const [popoverAnchor, setPopoverAnchor] = React.useState(null); const [, setProgressTick] = React.useState(0); const [dataReceivedBlink, setDataReceivedBlink] = React.useState(false); // ✅ 데이터 수신 깜빡임 const lastReceivedTimestampRef = React.useRef(0); React.useEffect(() => { if (!popoverAnchor) return; const id = setInterval(() => setProgressTick((t) => t + 1), 1000); return () => clearInterval(id); }, [popoverAnchor]); // ✅ connectionList의 lastReceivedAt 변화 감지 → 깜빡임 트리거 React.useEffect(() => { if (connectionList.length === 0) return; const latestReceived = Math.max(...connectionList.map(c => c.lastReceivedAt)); if (latestReceived > 0 && latestReceived !== lastReceivedTimestampRef.current) { lastReceivedTimestampRef.current = latestReceived; setDataReceivedBlink(true); setTimeout(() => setDataReceivedBlink(false), 300); // 300ms 후 자동 꺼짐 } }, [connectionList]); const config = WS_STATUS_CONFIG[status as ConnectionStatus] ?? WS_STATUS_CONFIG.disconnected; const getReceiveProgress = (lastReceivedAt: number) => { if (!lastReceivedAt) return 0; const elapsed = (Date.now() - lastReceivedAt) / 1000; if (elapsed <= 5) return 100; if (elapsed >= 30) return 0; return Math.round(100 - ((elapsed - 5) / 25) * 100); }; return ( <> setPopoverAnchor(e.currentTarget)} sx={{ display: 'flex', alignItems: 'center', gap: 0.75, px: 1, py: 0.5, borderRadius: 1, bgcolor: 'action.hover', fontSize: '0.75rem', color: config.color, cursor: 'pointer', '&:hover': { bgcolor: 'action.selected' }, // ✅ 데이터 수신 시 연두색 형광 깜빡임 ...(dataReceivedBlink && status === 'connected' ? { boxShadow: '0 0 10px 3px rgba(204, 255, 0, 0.7), inset 0 0 8px 2px rgba(204, 255, 0, 0.3)', transition: 'box-shadow 0.15s ease-out', } : {}), }} > {config.label} setPopoverAnchor(null)} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} transformOrigin={{ vertical: 'top', horizontal: 'center' }} slotProps={{ paper: { sx: { mt: 0.5, minWidth: 280 } } }} > WebSocket 연결 목록 {connectionList.length === 0 ? ( 구독 중인 차트가 없습니다. ) : ( {connectionList.map((item) => { const progress = getReceiveProgress(item.lastReceivedAt); const hasNoData = item.candleCount === 0; const displayName = item.koreanName || item.market; return ( {displayName} {item.timeframe} · 캔들 {item.candleCount}개 = 80 ? 'success.main' : progress >= 40 ? 'warning.main' : 'grey.400', }, }} /> {hasNoData && ( 데이터 미수신: Redis 수집 대기, 거래량 부족, 또는 해당 봉 데이터 미생성 가능 )} ); })} )} ※ 캔들 0개: market-data 서비스에서 해당 종목 수집 중이거나, 해당 타임프레임 데이터가 아직 생성되지 않았을 수 있습니다. {(status === 'disconnected' || status === 'error') && ctx?.reconnect && ( )} ); };