diff --git a/frontend/src/components/virtual/VirtualTargetCardChart.tsx b/frontend/src/components/virtual/VirtualTargetCardChart.tsx index 4d067f0..9542d46 100644 --- a/frontend/src/components/virtual/VirtualTargetCardChart.tsx +++ b/frontend/src/components/virtual/VirtualTargetCardChart.tsx @@ -16,6 +16,7 @@ import { useHistoryLoader } from '../../hooks/useHistoryLoader'; import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; import type { ChartManager } from '../../utils/ChartManager'; import { + buildVirtualTradingCardChartIndicators, buildVirtualTradingChartIndicators, resolveStrategyPrimaryTimeframe, resolveStrategyTimeframes, @@ -29,7 +30,6 @@ interface Props { theme?: Theme; running?: boolean; chartRealtimeSource?: ChartRealtimeSource; - /** 차트 설정 — 지표 가격축 라벨·설명 */ chartSeriesPriceLabels?: boolean; chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions; /** 전체보기 분할 pane — 캔버스 높이 100% */ @@ -65,10 +65,11 @@ const VirtualTargetCardChart: React.FC = ({ setTimeframe(resolveStrategyPrimaryTimeframe(strategy)); }, [strategy, market, chartTimeframe]); - const indicators = useMemo( - () => buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig), - [strategy, getParams, getVisualConfig], - ); + const indicators = useMemo(() => ( + fillHeight + ? buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig) + : buildVirtualTradingCardChartIndicators(strategy, getParams, getVisualConfig) + ), [strategy, getParams, getVisualConfig, fillHeight]); const effectiveIndicators = useMemo( () => indicators.filter(ind => ind.timeframeVisibility?.[timeframe] !== false), @@ -92,6 +93,10 @@ const VirtualTargetCardChart: React.FC = ({ if (!pending || !mgr || barsMarketRef.current !== marketRef.current) return; pendingBarRef.current = null; mgr.updateBar(pending); + const wrap = canvasWrapRef.current; + if (wrap && wrap.clientHeight > 0) { + mgr.resetPaneHeights(wrap.clientHeight); + } }, []); const applyRealtimeBar = useCallback((bar: OHLCVBar, append: boolean) => { @@ -134,6 +139,20 @@ const VirtualTargetCardChart: React.FC = ({ barsMarketRef.current = barsMarket; + const barsReady = bars.length >= 2 && barsMarket === market; + + const applyPaneHeights = useCallback(() => { + const wrap = canvasWrapRef.current; + const mgr = managerRef.current; + if (!wrap || wrap.clientHeight <= 0 || !mgr?.hasMainSeries()) return; + mgr.resetPaneHeights(wrap.clientHeight); + }, []); + + const requestChartReload = useCallback(() => { + chartReloadTriggeredRef.current = true; + setChartReloadTick(t => t + 1); + }, []); + useLayoutEffect(() => { chartLiveReadyRef.current = false; pendingBarRef.current = null; @@ -141,23 +160,41 @@ const VirtualTargetCardChart: React.FC = ({ }, [market, timeframe]); useEffect(() => { - const timers = [100, 200, 500, 1000, 2000].map(delay => + const wrap = canvasWrapRef.current; + if (!wrap) return; + + const timers = [100, 300, 800, 1500, 2500].map(delay => setTimeout(() => { - const wrap = canvasWrapRef.current; - const mgr = managerRef.current; - if (!wrap) return; - const h = wrap.clientHeight; + if (!canvasWrapRef.current) return; + const h = canvasWrapRef.current.clientHeight; if (h <= 0) return; - if (mgr?.hasMainSeries()) { - mgr.resetPaneHeights(h); - } else if (!chartReloadTriggeredRef.current && delay >= 1000 && bars.length > 0) { - chartReloadTriggeredRef.current = true; - setChartReloadTick(t => t + 1); + if (managerRef.current?.hasMainSeries()) { + managerRef.current.resetPaneHeights(h); + } else if (!chartReloadTriggeredRef.current && delay >= 800 && barsReady) { + requestChartReload(); } }, delay), ); - return () => timers.forEach(clearTimeout); - }, [market, timeframe, bars.length]); + + const ro = new ResizeObserver(() => applyPaneHeights()); + ro.observe(wrap); + + return () => { + timers.forEach(clearTimeout); + ro.disconnect(); + }; + }, [market, timeframe, barsReady, applyPaneHeights, requestChartReload]); + + useEffect(() => { + if (!barsReady) return; + if (managerRef.current?.hasMainSeries()) { + applyPaneHeights(); + return; + } + if (!chartReloadTriggeredRef.current) { + requestChartReload(); + } + }, [barsReady, applyPaneHeights, requestChartReload]); useEffect(() => { if (!useUpbit) return; @@ -195,8 +232,12 @@ const VirtualTargetCardChart: React.FC = ({ 과거 데이터 로딩… )} + {!isLoading && !barsReady && ( +
캔들 데이터 수집 중…
+ )} = ({ logScale={false} drawingsLocked drawingsVisible={false} - displayTimezone={DEFAULT_DISPLAY_TIMEZONE} onCrosshair={noop as (d: LegendData | null) => void} onManagerReady={mgr => { managerRef.current = mgr; }} onAddDrawing={noop as (d: Drawing) => void} diff --git a/frontend/src/styles/virtualTradingDashboard.css b/frontend/src/styles/virtualTradingDashboard.css index 611cf39..1d27b61 100644 --- a/frontend/src/styles/virtualTradingDashboard.css +++ b/frontend/src/styles/virtualTradingDashboard.css @@ -1313,8 +1313,19 @@ color: var(--accent, #3f7ef5); } -.vtd-card--chart-mode { +.vtd-card.vtd-card--chart-mode { min-height: 420px; + align-self: stretch; +} + +.vtd-card.vtd-card--chart-mode > .vtd-card-chart-panel { + flex: 1; + min-height: 0; +} + +.vtd-grid:has(.vtd-card--chart-mode) { + grid-auto-rows: minmax(440px, auto); + align-items: stretch; } .vtd-card-chart-panel { diff --git a/frontend/src/utils/strategyToChartIndicators.ts b/frontend/src/utils/strategyToChartIndicators.ts index 67ea4c0..8783bda 100644 --- a/frontend/src/utils/strategyToChartIndicators.ts +++ b/frontend/src/utils/strategyToChartIndicators.ts @@ -266,3 +266,12 @@ export function buildVirtualTradingChartIndicators( if (!defaultIchimoku) return strategyIndicators; return [defaultIchimoku, ...strategyIndicators]; } + +/** 가상매매 카드 인라인 차트 — 캔들 영역 확보(일목·다중 지표 pane 제외, 전략 지표 최대 1개) */ +export function buildVirtualTradingCardChartIndicators( + strategy: StrategyDto | undefined, + getParams: GetParams, + getVisual: GetVisual, +): IndicatorConfig[] { + return buildStrategyChartIndicators(strategy, getParams, getVisual).slice(0, 1); +}