From a0c86006073f3b033d519fa270f826357fd10b8d Mon Sep 17 00:00:00 2001 From: Macbook Date: Fri, 5 Jun 2026 21:28:11 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=B0=A8?= =?UTF-8?q?=ED=8A=B8=20=EC=BA=94=EB=93=A4=EC=B0=A8=ED=8A=B8=20=EB=86=92?= =?UTF-8?q?=EC=9D=B4=20=EC=A1=B0=EC=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.css | 32 +++++++ frontend/src/components/TradingChart.tsx | 96 ++++++++++++++++++- .../backtest/BacktestAnalysisChart.tsx | 48 +++++++--- frontend/src/utils/ChartManager.ts | 17 ++-- 4 files changed, 174 insertions(+), 19 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index c9899b3..afe7b45 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1036,6 +1036,38 @@ html.theme-blue { position: relative; } +/* ── 캔들/보조지표 pane 분리선 드래그 핸들 ────────────────────────── */ +.chart-pane-resize-handle { + position: absolute; + left: 0; + /* 우측 툴바(--sidebar-w) 제외 — 툴바 위에 겹치지 않도록 */ + right: var(--sidebar-w, 44px); + height: 10px; + margin-top: -5px; + cursor: ns-resize; + z-index: 15; + touch-action: none; + user-select: none; +} + +.chart-pane-resize-handle::after { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 50%; + transform: translateY(-50%); + height: 2px; + border-radius: 1px; + background: transparent; + transition: background 0.15s ease; +} + +.chart-pane-resize-handle:hover::after, +.chart-pane-resize-handle:active::after { + background: rgba(100, 120, 180, 0.55); +} + /* 우측 세로 툴바 — 가격축 라벨 오른쪽 전용 영역 */ .chart-right-toolbar { width: var(--sidebar-w, 44px); diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index 06fc989..c047511 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -250,6 +250,8 @@ const TradingChart: React.FC = ({ const candleOnlyModeRef = useRef(false); const paneAreaRatioRef = useRef(paneAreaRatio); paneAreaRatioRef.current = paneAreaRatio; + /** 사용자 드래그로 설정한 pane 비율 — prop 변경 시 초기화 */ + const userDraggedRatioRef = useRef<{ candle: number; aux: number } | null>(null); const lastCrosshairPriceRef = useRef(null); const onTradeOrderRequestRef = useRef(onTradeOrderRequest); onTradeOrderRequestRef.current = onTradeOrderRequest; @@ -276,6 +278,13 @@ const TradingChart: React.FC = ({ const indicatorSyncInFlightRef = useRef(false); const [paneLegendPaused, setPaneLegendPaused] = useState(false); const [chartBodyHeight, setChartBodyHeight] = useState(0); + /** 캔들 pane 하단 Y 위치 (드래그 핸들용) — null 이면 보조지표 없음 */ + const [candlePaneBottomY, setCandlePaneBottomY] = useState(null); + const paneResizeDragRef = useRef<{ + startClientY: number; + startCandleH: number; + wrapperH: number; + } | null>(null); const pendingIndicatorResyncRef = useRef(false); const indicatorReloadGenRef = useRef(0); const prevMarket = useRef(market); @@ -324,6 +333,74 @@ const TradingChart: React.FC = ({ return () => ro.disconnect(); }, [chartMgr]); + /** 캔들 pane 하단 Y 추적 — 보조지표 있을 때만 드래그 핸들 표시 */ + useEffect(() => { + const mgr = managerRef.current; + if (!mgr || !chartMgr) return; + + const updateHandle = () => { + const layouts = mgr.getPaneLayouts(); + const candle = layouts.find(l => l.paneIndex === 0); + const hasAux = layouts.some(l => l.paneIndex >= 2 && l.height > 20); + if (candle && candle.height > 20 && hasAux) { + setCandlePaneBottomY(candle.topY + candle.height); + } else { + setCandlePaneBottomY(null); + } + }; + + updateHandle(); + return mgr.subscribePaneLayout(updateHandle); + }, [chartMgr]); + + /** 캔들/보조지표 영역 분리선 드래그 핸들러 */ + const onPaneResizePointerDown = useCallback((e: React.PointerEvent) => { + const mgr = managerRef.current; + const wrapper = wrapperRef.current; + if (!mgr || !wrapper) return; + e.preventDefault(); + e.stopPropagation(); + (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); + const layouts = mgr.getPaneLayouts(); + const candle = layouts.find(l => l.paneIndex === 0); + paneResizeDragRef.current = { + startClientY: e.clientY, + startCandleH: candle?.height ?? 200, + wrapperH: wrapper.clientHeight, + }; + }, []); + + const onPaneResizePointerMove = useCallback((e: React.PointerEvent) => { + const drag = paneResizeDragRef.current; + const mgr = managerRef.current; + const wrapper = wrapperRef.current; + const container = containerRef.current; + if (!drag || !mgr || !wrapper || !container) return; + + const dy = e.clientY - drag.startClientY; + const H = drag.wrapperH; + const MIN_CANDLE = 100; + const MIN_AUX = 60; + const newCandleH = Math.max(MIN_CANDLE, Math.min(H - MIN_AUX, drag.startCandleH + dy)); + const newCandle = newCandleH / H; + const newAux = 1 - newCandle; + + mgr.setPaneAreaRatio(newCandle, newAux); + // applyPaneLayout 이 ResizeObserver 로 재실행돼도 이 비율 유지 + userDraggedRatioRef.current = { candle: newCandle, aux: newAux }; + const required = mgr.resetPaneHeights(H); + + // 스크롤 영역이 필요한 경우 컨테이너 확장 + const newContainerH = required > H + 4 ? required : H; + if (container.style.height !== `${newContainerH}px`) { + container.style.height = `${newContainerH}px`; + } + }, []); + + const onPaneResizePointerUp = useCallback(() => { + paneResizeDragRef.current = null; + }, []); + const toggleCandleOnly = useCallback(() => { if (candleOnlyLocked) return; setCandleOnlyMode(v => !v); @@ -410,7 +487,8 @@ const TradingChart: React.FC = ({ if (mgr.isCandleOnlyLayout()) { mgr.restoreFromCandleFullscreen(wrapperH); } - const ratio = paneAreaRatioRef.current; + // 사용자 드래그 비율 우선, 없으면 prop 기반 비율 + const ratio = userDraggedRatioRef.current ?? paneAreaRatioRef.current; if (ratio && ratio.aux > 0) { mgr.setPaneAreaRatio(ratio.candle, ratio.aux); } else { @@ -443,6 +521,11 @@ const TradingChart: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [paneLayoutClamp, paneAreaRatio]); + /** paneAreaRatio prop 이 외부에서 변경되면 드래그 비율 초기화 (전략 변경 등) */ + useEffect(() => { + userDraggedRatioRef.current = null; + }, [paneAreaRatio]); + /** pane 레이아웃 + 가격·시간축 정상화 (느린 네트워크·레이아웃 지연 후 복구용) */ const normalizeChartViewport = useCallback((mgr: ChartManager) => { applyPaneLayout(mgr); @@ -1614,6 +1697,17 @@ const TradingChart: React.FC = ({ ref={containerRef} className="chart-container" /> + {/* 캔들 pane 하단 드래그 핸들 — 캔들/보조지표 높이 비율 조절 */} + {candlePaneBottomY != null && !candleOnlyMode && !paneLayoutClamp && chartPaintReady && ( +
+ )} {chartMgr && !candleOnlyMode && showPaneLegend && chartPaintReady && ( ) => Record, ): IndicatorConfig[] { @@ -89,11 +90,32 @@ function defaultOverlayIndicators( params, plots: def.plots, hlines: def.hlines ?? [], - timeframeVisibility: { '1m': true, '3m': true, '5m': true, '10m': true, '15m': true, '30m': true, '1h': true, '4h': true, '1D': true, '1W': true, '1M': true }, + timeframeVisibility: ALL_TF_VISIBLE, })); return [cfg]; } +/** 전략 미선택 시 기본 오실레이터(RSI, MACD)를 TradingChart sub-pane 으로 추가 */ +function defaultOscillatorIndicators( + getParams: (t: string, d?: Record) => Record, +): IndicatorConfig[] { + const result: IndicatorConfig[] = []; + for (const type of ['RSI', 'MACD']) { + const def = getIndicatorDef(type); + if (!def) continue; + const params: Record = { ...def.defaultParams, ...getParams(type, def.defaultParams) }; + result.push(enrichIndicatorConfig({ + id: newIndId(), + type, + params, + plots: def.plots, + hlines: def.hlines ?? [], + timeframeVisibility: ALL_TF_VISIBLE, + })); + } + return result; +} + const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true; /** @@ -156,9 +178,11 @@ const BacktestAnalysisChart: React.FC = ({ }, [strategyId]); /** - * overlayVisibility 가 없는 경우(BacktestHistoryPage 컨텍스트)에는 - * 보조지표(오실레이터)를 TradingChart sub-pane 이 아닌 아래쪽 - * StrategyOscillatorPanes 섹션에서 표시한다. + * 백테스트 화면(overlayVisibility 없음)에서 보조지표를 TradingChart sub-pane 으로 통합. + * - 기존: 오실레이터를 SVG 기반 StrategyOscillatorPanes 에서 별도 렌더링 + * → 캔들 차트와 시간 축(x축) 불일치, 우측 가격 라벨 없음 + * - 변경: 모든 지표를 TradingChart sub-pane 으로 통합 + * → 시간 축 완전 동기화, 우측 가격 라벨 표시 */ const showOscillatorPanel = !overlayVisibility; @@ -166,13 +190,15 @@ const BacktestAnalysisChart: React.FC = ({ let inds: IndicatorConfig[]; if (strategy) { inds = buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig); - // 오실레이터 패널을 별도 섹션으로 표시할 때는 TradingChart 에서 제거 - if (showOscillatorPanel) { - inds = inds.filter(i => isOverlayType(i.type)); - } + // [수정] 오실레이터 필터 제거 — TradingChart sub-pane 에서 직접 렌더링 + // (기존: showOscillatorPanel 시 overlay 지표만 남기고 나머지는 SVG 패널로 분리) inds = inds.filter(i => i.timeframeVisibility?.[chartTimeframe] !== false); } else { inds = defaultOverlayIndicators(getParams); + // 전략 미선택 시 기본 오실레이터(RSI, MACD)도 TradingChart sub-pane 으로 추가 + if (showOscillatorPanel) { + inds = [...inds, ...defaultOscillatorIndicators(getParams)]; + } } if (overlayVisibility) { inds = ensurePaperChartOverlays(inds, getParams, getVisualConfig); @@ -467,9 +493,7 @@ const BacktestAnalysisChart: React.FC = ({
표시할 캔들 데이터가 없습니다.
)}
- {showOscillatorPanel && !loading && bars.length > 0 && ( - - )} + {/* 오실레이터 pane 은 TradingChart 내부 sub-pane 으로 통합 (시간 축 동기화) */} ); diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index 7a99f3c..0226511 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -4045,9 +4045,10 @@ export class ChartManager { private _minIndPx(H: number): number { if (this._compactPaneLayout) { - return Math.min(56, Math.max(28, Math.round(H * 0.2))); + return Math.min(44, Math.max(24, Math.round(H * 0.15))); } - return 80; + // 일반 레이아웃: 최소 높이를 줄여 한 화면에 더 많은 보조지표 pane 표시 + return 50; } private _paneHeightFractions( @@ -4061,12 +4062,16 @@ export class ChartManager { } if (this._compactPaneLayout && N === 1 && availableHeight != null && availableHeight < 400) { const mainFrac = 0.52; - return { mainFrac, eachIndFrac: Math.max(0.28, 1 - mainFrac - volFrac) }; + return { mainFrac, eachIndFrac: Math.max(0.24, 1 - mainFrac - volFrac) }; } - if (N <= 3) { - return { mainFrac: 0.5, eachIndFrac: (0.5 - volFrac) / N }; + // 보조지표가 많을수록 캔들 차트 비율을 높여 가독성 확보 + if (N <= 2) { + return { mainFrac: 0.60, eachIndFrac: (0.40 - volFrac) / N }; } - const mainFrac = Math.max(1 / 3, volFrac + 0.05); + if (N <= 4) { + return { mainFrac: 0.55, eachIndFrac: (0.45 - volFrac) / N }; + } + const mainFrac = Math.max(0.40, volFrac + 0.05); return { mainFrac, eachIndFrac: (1 - mainFrac - volFrac) / N }; }