실시간 차트 멀티모드 수정

This commit is contained in:
Macbook
2026-05-26 01:16:36 +09:00
parent 1e11884fef
commit 173b47d485
15 changed files with 302 additions and 121 deletions
+50 -20
View File
@@ -24,6 +24,7 @@ import { useLiveReceiveFlash } from '../hooks/useLiveReceiveFlash';
import VirtualLiveBadge from './virtual/VirtualLiveBadge';
import type { VirtualLiveStatus } from '../hooks/useVirtualTargetLiveStatus';
import { invalidateMarketCache } from '../utils/requestCache';
import { DISPLAY_COUNT } from '../hooks/useUpbitData';
import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader';
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
import {
@@ -162,6 +163,8 @@ export interface ChartSlotProps {
displayTimezone?: string;
/** 멀티차트 — 가상투자 카드 차트처럼 컴팩트 카드 UI */
compactMode?: boolean;
/** 실시간 수신 시 카드 아웃라인 형광 연두 음영 (설정 연동) */
chartLiveReceiveHighlight?: boolean;
}
const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot({
@@ -183,6 +186,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
chartRealtimeSource = 'BACKEND_STOMP',
displayTimezone,
compactMode = false,
chartLiveReceiveHighlight = true,
}, ref) {
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings();
@@ -200,10 +204,11 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
const [showMainChartModal, setShowMainChartModal] = useState(false);
const [mainChartStyle, setMainChartStyle] = useState<MainChartStyle>(DEFAULT_MAIN_CHART_STYLE);
const [showMarket, setShowMarket] = useState(false);
// 슬롯 컨테이너 rect: MarketSearchPanel 위치 계산에 사용
// MarketSearchPanel 위치: compact=종목명 버튼, 일반=슬롯 전체
const [slotAnchorRect, setSlotAnchorRect] = useState<DOMRect | null>(null);
const managerRef = useRef<ChartManager | null>(null);
const compactNameRef = useRef<HTMLButtonElement>(null);
// initialSymbol 도 초기값으로 포함: requestAnimationFrame 이전에 올바른 값 보장
const symbolRef = useRef(initialSymbol ?? forcedSymbol ?? 'KRW-BTC');
const timeframeRef = useRef<Timeframe>(forcedTf ?? '1D');
@@ -362,6 +367,8 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
// → onDataLoaded 콜백에서 재적용 (멀티차트 첫 마운트 시 타이밍 문제 해결)
const pendingSyncVisibleRef = useRef<{ from: number; to: number } | null>(null);
const pendingSyncLogicalRef = useRef<{ from: number; to: number } | null>(null);
/** 종목·TF 전환 직후 sync range 재적용 스킵 (이전 종목 뷰포트가 새 차트에 적용되는 것 방지) */
const skipSyncApplyRef = useRef(false);
// ── Crosshair sync: syncTime → setCrosshairAtTime ────────────────────
useEffect(() => {
@@ -371,7 +378,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
// ── Time sync: syncVisibleRange 외부 적용 ────────────────────────────
useEffect(() => {
if (!syncVisibleRange) return;
if (!syncVisibleRange || skipSyncApplyRef.current) return;
const mgr = managerRef.current;
if (!mgr || mgr.getRawBarsLength() === 0) {
// 데이터 로드 전 → pending 저장 후 onDataLoaded 에서 재적용
@@ -385,7 +392,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
// ── Date range sync: syncLogicalRange 외부 적용 ──────────────────────
useEffect(() => {
if (!syncLogicalRange) return;
if (!syncLogicalRange || skipSyncApplyRef.current) return;
const mgr = managerRef.current;
if (!mgr || mgr.getRawBarsLength() === 0) {
pendingSyncLogicalRef.current = syncLogicalRange;
@@ -409,6 +416,9 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
reloadTriggeredRef.current = false;
chartLiveReadyRef.current = false;
pendingRealtimeBarRef.current = null;
pendingSyncVisibleRef.current = null;
pendingSyncLogicalRef.current = null;
skipSyncApplyRef.current = true;
}, [symbol, timeframe]);
useEffect(() => {
const checks = [200, 500, 1000, 2000].map(delay =>
@@ -519,6 +529,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
}, [useUpbit, wsStatus, latestBar]);
const receiving = useLiveReceiveFlash(receiveSignal, compactMode && useUpbit && wsStatus === 'connected');
const highlightReceiving = chartLiveReceiveHighlight && receiving;
// ── 인디케이터 핸들러 ─────────────────────────────────────────────────
const handleIndicatorSave = useCallback((updated: IndicatorConfig) => {
@@ -590,11 +601,29 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
// ── 심볼 선택 ─────────────────────────────────────────────────────────
const handleSymbolSelect = useCallback((s: string) => {
if (isUpbitMarket(s)) invalidateMarketCache(s);
pendingSyncVisibleRef.current = null;
pendingSyncLogicalRef.current = null;
skipSyncApplyRef.current = true;
setSymbol(s);
setLegend(null);
setShowMarket(false);
setSlotAnchorRect(null);
}, []);
const openMarketSearch = useCallback((e?: React.MouseEvent) => {
e?.stopPropagation();
onActivate();
if (!showMarket) {
const anchorEl = compactMode
? compactNameRef.current
: (slotContainerRef.current?.closest('.chart-slot') as HTMLElement | null);
setSlotAnchorRect(anchorEl?.getBoundingClientRect() ?? null);
} else {
setSlotAnchorRect(null);
}
setShowMarket(v => !v);
}, [compactMode, onActivate, showMarket]);
const displayKoName = compactKoName(symbol);
const trendUp = compactMode && currentBar != null && dailyChangePct >= 0;
const trendDown = compactMode && currentBar != null && dailyChangePct < 0;
@@ -605,7 +634,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
'chart-slot',
active ? 'active' : '',
compactMode ? 'chart-slot--compact' : '',
receiving ? 'chart-slot--receiving' : '',
highlightReceiving ? 'chart-slot--receiving' : '',
trendUp ? 'chart-slot--trend-up' : '',
trendDown ? 'chart-slot--trend-down' : '',
].filter(Boolean).join(' ')}
@@ -620,17 +649,17 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
<div className="slot-compact-row">
<button
type="button"
ref={compactNameRef}
className={`slot-compact-name${showMarket ? ' open' : ''}`}
title={`${displayKoName} — 종목 변경`}
onClick={() => {
if (!showMarket) {
const slotEl = slotContainerRef.current?.closest('.chart-slot') as HTMLElement | null;
setSlotAnchorRect(slotEl?.getBoundingClientRect() ?? null);
}
setShowMarket(v => !v);
}}
aria-expanded={showMarket}
aria-haspopup="listbox"
onClick={openMarketSearch}
>
{displayKoName}
<svg width="11" height="11" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6" style={{ flexShrink: 0 }} aria-hidden>
<circle cx="6" cy="6" r="4"/><line x1="9.5" y1="9.5" x2="13" y2="13"/>
</svg>
<span className="slot-compact-name-text">{displayKoName}</span>
</button>
{currentBar && (
@@ -676,13 +705,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
<button
className={`slot-sym-btn${showMarket ? ' open' : ''}`}
title="종목 변경"
onClick={() => {
if (!showMarket) {
const slotEl = slotContainerRef.current?.closest('.chart-slot') as HTMLElement | null;
setSlotAnchorRect(slotEl?.getBoundingClientRect() ?? null);
}
setShowMarket(v => !v);
}}
onClick={openMarketSearch}
>
<svg width="11" height="11" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6" style={{ flexShrink: 0 }}>
<circle cx="6" cy="6" r="4"/><line x1="9.5" y1="9.5" x2="13" y2="13"/>
@@ -720,8 +743,9 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
<MarketSearchPanel
currentMarket={symbol}
onSelect={s => { handleSymbolSelect(s); }}
onClose={() => setShowMarket(false)}
onClose={() => { setShowMarket(false); setSlotAnchorRect(null); }}
anchorRect={slotAnchorRect}
anchorPlacement={compactMode ? 'dropdown' : 'slot'}
/>,
document.body,
)}
@@ -809,6 +833,12 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
// 첫 마운트 시 pending 상태였던 sync range 를 재적용한다
const mgr = managerRef.current;
if (!mgr) return;
if (skipSyncApplyRef.current) {
skipSyncApplyRef.current = false;
const fb = mgr.hasIchimoku() ? 28 : 0;
mgr.setInitialVisibleRange(DISPLAY_COUNT, fb);
return;
}
if (pendingSyncVisibleRef.current) {
isSyncingTimeRef.current = true;
mgr.applyVisibleTimeRange(
+106 -63
View File
@@ -46,11 +46,12 @@ interface MarketSearchPanelProps {
onSelect: (market: string) => void;
onClose: () => void;
/**
* 슬롯 내 심볼 버튼 클릭 시 전달되는 슬롯 컨테이너 rect.
* 제공되면 해당 슬롯 영역 안에서 패널이 표시된다.
* 슬롯/버튼 rect. anchorPlacement 에 따라 패널 위치가 결정된다.
* 없으면 기본(전체화면 좌측 고정) 위치로 표시된다.
*/
anchorRect?: DOMRect | null;
/** slot=슬롯 영역 채움, dropdown=클릭 지점 아래 드롭다운 */
anchorPlacement?: 'slot' | 'dropdown';
/** overlay=팝업/고정패널, embedded=좌측 패널 인라인 목록 */
variant?: 'overlay' | 'embedded';
/** 외부 입력과 검색어 동기화 */
@@ -67,7 +68,7 @@ interface MarketSearchPanelProps {
}
export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
currentMarket, onSelect, onClose, anchorRect, initialQuery = '',
currentMarket, onSelect, onClose, anchorRect, anchorPlacement = 'slot', initialQuery = '',
variant = 'overlay', query: controlledQuery, onQueryChange,
actionMode = 'favorite', onAdd, addedMarkets,
}) => {
@@ -233,36 +234,60 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
// 인라인 스타일로 z-index를 명시해 패널이 항상 최상단에 렌더되게 보장한다.
const PANEL_Z = 99999;
const DROPDOWN_H = 480;
const isCompactDropdown = anchorPlacement === 'dropdown' && !embedded;
const panelStyle: React.CSSProperties | undefined = embedded
? undefined
: anchorRect
? {
position: 'fixed',
top: anchorRect.top + HEADER_H,
left: anchorRect.left,
width: Math.max(anchorRect.width, MIN_W),
height: anchorRect.height - HEADER_H,
maxWidth: anchorRect.width,
zIndex: PANEL_Z,
// document.body 포털 시 CSS var()가 상속되지 않아 배경이 투명해지는 문제 방지
background: 'var(--bg2, #24283b)',
color: 'var(--text, #c0caf5)',
border: '1px solid var(--border, rgba(122,162,247,0.15))',
}
? anchorPlacement === 'dropdown'
? {
position: 'fixed',
top: anchorRect.bottom + 4,
left: anchorRect.left,
width: Math.max(420, anchorRect.width, MIN_W),
height: Math.min(DROPDOWN_H, window.innerHeight - anchorRect.bottom - 12),
maxWidth: Math.min(520, window.innerWidth - anchorRect.left - 8),
minHeight: 320,
zIndex: PANEL_Z,
background: 'var(--bg2, #24283b)',
color: 'var(--text, #c0caf5)',
border: '1px solid var(--border, rgba(122,162,247,0.15))',
}
: {
position: 'fixed',
top: anchorRect.top + HEADER_H,
left: anchorRect.left,
width: Math.max(anchorRect.width, MIN_W),
height: anchorRect.height - HEADER_H,
maxWidth: anchorRect.width,
zIndex: PANEL_Z,
// document.body 포털 시 CSS var()가 상속되지 않아 배경이 투명해지는 문제 방지
background: 'var(--bg2, #24283b)',
color: 'var(--text, #c0caf5)',
border: '1px solid var(--border, rgba(122,162,247,0.15))',
}
: undefined; // undefined → CSS 기본값(.msp-panel) 그대로 사용
// 슬롯 전용 backdrop: 해당 슬롯 영역만 반투명 오버레이로 덮어 패널이 명확히 보이게 함
// backdrop: dropdown=전체 화면, slot=해당 슬롯 영역만
const backdropStyle: React.CSSProperties | undefined = anchorRect
? {
position: 'fixed',
top: anchorRect.top,
left: anchorRect.left,
width: anchorRect.width,
height: anchorRect.height,
zIndex: PANEL_Z - 1,
background: 'rgba(0,0,0,0.45)',
backdropFilter: 'none',
}
? anchorPlacement === 'dropdown'
? {
position: 'fixed',
inset: 0,
zIndex: PANEL_Z - 1,
background: 'rgba(0,0,0,0.28)',
}
: {
position: 'fixed',
top: anchorRect.top,
left: anchorRect.left,
width: anchorRect.width,
height: anchorRect.height,
zIndex: PANEL_Z - 1,
background: 'rgba(0,0,0,0.45)',
backdropFilter: 'none',
}
: undefined;
const handleRowSelect = useCallback((market: string) => {
@@ -280,7 +305,12 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
)}
<div
className={`msp-panel${anchorRect ? ' msp-panel--slot' : ''}${embedded ? ' msp-panel--embedded' : ''}`}
className={[
'msp-panel',
anchorRect && anchorPlacement === 'dropdown' ? 'msp-panel--dropdown' : '',
anchorRect && anchorPlacement === 'slot' ? 'msp-panel--slot' : '',
embedded ? 'msp-panel--embedded' : '',
].filter(Boolean).join(' ')}
style={panelStyle}
>
@@ -317,14 +347,18 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
</div>
)}
<div className={`msp-header${embedded ? ' msp-header--embedded' : ''}`}>
<span className="msp-col msp-col-fav">{embedded ? '추가' : ''}</span>
<span className="msp-col msp-col-name"></span>
<div className={`msp-header${embedded ? ' msp-header--embedded' : ''}${isCompactDropdown ? ' msp-header--compact' : ''}`}>
{!isCompactDropdown && (
<span className="msp-col msp-col-fav">{embedded ? '추가' : ''}</span>
)}
<span className="msp-col msp-col-name">{isCompactDropdown ? '종목명' : '한글명'}</span>
{!embedded && (
<>
<span className="msp-col msp-col-price"></span>
<span className="msp-col msp-col-change"></span>
<span className="msp-col msp-col-vol"></span>
{!isCompactDropdown && (
<span className="msp-col msp-col-vol"></span>
)}
</>
)}
</div>
@@ -348,40 +382,47 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
return (
<div
key={m.market}
className={`msp-item${embedded ? ' msp-item--embedded' : ''}${isActive ? ' active' : ''}`}
className={[
'msp-item',
embedded ? 'msp-item--embedded' : '',
isCompactDropdown ? 'msp-item--compact' : '',
isActive ? 'active' : '',
].filter(Boolean).join(' ')}
onClick={() => handleRowSelect(m.market)}
>
{actionMode === 'add' ? (
<button
type="button"
className={`msp-add-btn${isAdded ? ' msp-add-btn--done' : ''}`}
onClick={e => handleAddClick(m.market, e)}
disabled={isAdded}
title={isAdded ? '이미 추가됨' : '투자대상에 추가'}
>
{isAdded ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
)}
</button>
) : (
<span
className={`msp-col msp-col-fav msp-star${isFav ? ' starred' : ''}`}
onClick={e => toggleFav(m.market, e)}
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
>
{isFav ? '★' : '☆'}
</span>
{!isCompactDropdown && (
actionMode === 'add' ? (
<button
type="button"
className={`msp-add-btn${isAdded ? ' msp-add-btn--done' : ''}`}
onClick={e => handleAddClick(m.market, e)}
disabled={isAdded}
title={isAdded ? '이미 추가됨' : '투자대상에 추가'}
>
{isAdded ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
)}
</button>
) : (
<span
className={`msp-col msp-col-fav msp-star${isFav ? ' starred' : ''}`}
onClick={e => toggleFav(m.market, e)}
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
>
{isFav ? '★' : '☆'}
</span>
)
)}
<span className="msp-col msp-col-name">
<span className="msp-kor">{m.korean_name}</span>
<span className="msp-sym">{m.symbol}</span>
{!isCompactDropdown && <span className="msp-sym">{m.symbol}</span>}
</span>
{!embedded && (
@@ -396,9 +437,11 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
? '-'
: `${isUp ? '+' : ''}${safeToFixed(rate, 2)}%`}
</span>
<span className="msp-col msp-col-vol">
{tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'}
</span>
{!isCompactDropdown && (
<span className="msp-col msp-col-vol">
{tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'}
</span>
)}
</>
)}
</div>
+23
View File
@@ -96,6 +96,8 @@ interface SettingsPageProps {
onChartSeriesPriceLabels?: (v: boolean) => void;
chartVolumeVisible?: boolean;
onChartVolumeVisible?: (v: boolean) => void;
chartLiveReceiveHighlight?: boolean;
onChartLiveReceiveHighlight?: (v: boolean) => void;
chartLegendOptions?: ChartLegendVisibility;
onChartLegendOptionsChange?: (patch: Partial<ChartLegendVisibility>) => void;
paperTradingEnabled?: boolean;
@@ -565,6 +567,8 @@ interface ChartPanelProps {
onChartSeriesPriceLabels?: (v: boolean) => void;
chartVolumeVisible?: boolean;
onChartVolumeVisible?: (v: boolean) => void;
chartLiveReceiveHighlight?: boolean;
onChartLiveReceiveHighlight?: (v: boolean) => void;
chartLegendOptions?: ChartLegendVisibility;
onChartLegendOptionsChange?: (patch: Partial<ChartLegendVisibility>) => void;
}
@@ -577,6 +581,8 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
onChartSeriesPriceLabels,
chartVolumeVisible = true,
onChartVolumeVisible,
chartLiveReceiveHighlight = true,
onChartLiveReceiveHighlight,
chartLegendOptions,
onChartLegendOptionsChange,
}) => {
@@ -598,6 +604,19 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
<option value="UPBIT_DIRECT"> ()</option>
</select>
</SettingRow>
<SettingRow
label="실시간 수신 음영처리"
desc="켜면 실시간 데이터 수신 순간 멀티차트·가상투자 카드 아웃라인에 형광 연두색 glow가 표시됩니다. 끄면 아웃라인 음영 효과가 비활성화됩니다."
>
<label className="stg-toggle">
<input
type="checkbox"
checked={chartLiveReceiveHighlight}
onChange={e => onChartLiveReceiveHighlight?.(e.target.checked)}
/>
<span className="stg-toggle-slider" />
</label>
</SettingRow>
</SettingSection>
<SettingSection title="캔들 색상">
<SettingRow label="상승 캔들 색상" desc="양봉(상승) 캔들의 기본 색상입니다.">
@@ -1351,6 +1370,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
onChartSeriesPriceLabels,
chartVolumeVisible = true,
onChartVolumeVisible,
chartLiveReceiveHighlight = true,
onChartLiveReceiveHighlight,
chartLegendOptions,
onChartLegendOptionsChange,
tradeAlertSoundEnabled = true,
@@ -1428,6 +1449,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
onChartSeriesPriceLabels={onChartSeriesPriceLabels}
chartVolumeVisible={chartVolumeVisible}
onChartVolumeVisible={onChartVolumeVisible}
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
onChartLiveReceiveHighlight={onChartLiveReceiveHighlight}
chartLegendOptions={chartLegendOptions}
onChartLegendOptionsChange={onChartLegendOptionsChange}
/>
@@ -90,6 +90,7 @@ const VirtualTradingPage: React.FC<Props> = ({
const chartRealtimeSource = (appChartDefaults.chartRealtimeSource
?? 'BACKEND_STOMP') as ChartRealtimeSource;
const chartSeriesPriceLabels = appChartDefaults.chartSeriesPriceLabels;
const chartLiveReceiveHighlight = appChartDefaults.chartLiveReceiveHighlight;
useEffect(() => {
void Promise.all([
@@ -340,6 +341,7 @@ const VirtualTradingPage: React.FC<Props> = ({
theme={theme}
chartRealtimeSource={chartRealtimeSource}
chartSeriesPriceLabels={chartSeriesPriceLabels}
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
tickers={tickers}
viewMode={viewMode}
globalDisplayMode={globalDisplayMode}
@@ -36,6 +36,7 @@ interface Props {
theme?: Theme;
chartRealtimeSource?: ChartRealtimeSource;
chartSeriesPriceLabels?: boolean;
chartLiveReceiveHighlight?: boolean;
ticker?: TickerData;
}
@@ -59,6 +60,7 @@ const VirtualTargetCard: React.FC<Props> = ({
theme = 'dark',
chartRealtimeSource = 'BACKEND_STOMP',
chartSeriesPriceLabels = true,
chartLiveReceiveHighlight = true,
ticker,
}) => {
const ko = getKoreanName(market);
@@ -79,6 +81,7 @@ const VirtualTargetCard: React.FC<Props> = ({
return `${lastTickAt ?? 0}|${snapshot?.updatedAt ?? 0}`;
}, [running, lastTickAt, snapshot?.updatedAt]);
const receiving = useLiveReceiveFlash(receiveSignal, running);
const highlightReceiving = chartLiveReceiveHighlight && receiving;
return (
<div
@@ -86,7 +89,7 @@ const VirtualTargetCard: React.FC<Props> = ({
'vtd-card',
'vtd-card--signal',
running ? 'vtd-card--live' : '',
receiving ? 'vtd-card--receiving' : '',
highlightReceiving ? 'vtd-card--receiving' : '',
isDetail ? 'vtd-card--detail' : 'vtd-card--summary',
isChart ? 'vtd-card--chart-mode' : '',
selected ? 'vtd-card--selected' : '',
@@ -187,7 +190,7 @@ const VirtualTargetCard: React.FC<Props> = ({
<VirtualTargetSignalPanel
snapshot={snapshot}
viewMode={viewMode}
receiving={receiving}
receiving={highlightReceiving}
/>
)}
</div>
@@ -29,6 +29,7 @@ interface Props {
theme?: Theme;
chartRealtimeSource?: ChartRealtimeSource;
chartSeriesPriceLabels?: boolean;
chartLiveReceiveHighlight?: boolean;
ticker?: TickerData;
}
@@ -49,6 +50,7 @@ const VirtualTargetFocusView: React.FC<Props> = ({
theme = 'dark',
chartRealtimeSource = 'BACKEND_STOMP',
chartSeriesPriceLabels = true,
chartLiveReceiveHighlight = true,
ticker,
}) => {
const ko = getKoreanName(market);
@@ -60,9 +62,10 @@ const VirtualTargetFocusView: React.FC<Props> = ({
return `${lastTickAt ?? 0}|${snapshot?.updatedAt ?? 0}`;
}, [running, lastTickAt, snapshot?.updatedAt]);
const receiving = useLiveReceiveFlash(receiveSignal, running);
const highlightReceiving = chartLiveReceiveHighlight && receiving;
return (
<div className={`vtd-focus-wrap${receiving ? ' vtd-focus-wrap--receiving' : ''}`}>
<div className={`vtd-focus-wrap${highlightReceiving ? ' vtd-focus-wrap--receiving' : ''}`}>
<div className="vtd-focus-head">
<div className="vtd-focus-head-main">
<div className="vtd-focus-title">
@@ -129,7 +132,7 @@ const VirtualTargetFocusView: React.FC<Props> = ({
<VirtualTargetSignalPanel
snapshot={snapshot}
viewMode={viewMode}
receiving={receiving}
receiving={highlightReceiving}
/>
</div>
</section>
@@ -22,6 +22,7 @@ interface Props {
theme?: Theme;
chartRealtimeSource?: ChartRealtimeSource;
chartSeriesPriceLabels?: boolean;
chartLiveReceiveHighlight?: boolean;
tickers?: Map<string, TickerData>;
viewMode: VirtualCardViewMode;
globalDisplayMode: VirtualCardDisplayMode;
@@ -36,6 +37,7 @@ const VirtualTargetGrid: React.FC<Props> = ({
theme = 'dark',
chartRealtimeSource = 'BACKEND_STOMP',
chartSeriesPriceLabels = true,
chartLiveReceiveHighlight = true,
tickers,
viewMode,
globalDisplayMode,
@@ -116,6 +118,7 @@ const VirtualTargetGrid: React.FC<Props> = ({
theme={theme}
chartRealtimeSource={chartRealtimeSource}
chartSeriesPriceLabels={chartSeriesPriceLabels}
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
ticker={tickers?.get(focusTarget.market)}
/>
) : (
@@ -144,6 +147,7 @@ const VirtualTargetGrid: React.FC<Props> = ({
theme={theme}
chartRealtimeSource={chartRealtimeSource}
chartSeriesPriceLabels={chartSeriesPriceLabels}
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
ticker={tickers?.get(t.market)}
/>
);