실시간 차트 캔들차트 높이 조절

This commit is contained in:
Macbook
2026-06-05 21:28:11 +09:00
parent 8c82c029e6
commit a0c8600607
4 changed files with 174 additions and 19 deletions
+32
View File
@@ -1036,6 +1036,38 @@ html.theme-blue {
position: relative; 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 { .chart-right-toolbar {
width: var(--sidebar-w, 44px); width: var(--sidebar-w, 44px);
+95 -1
View File
@@ -250,6 +250,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
const candleOnlyModeRef = useRef(false); const candleOnlyModeRef = useRef(false);
const paneAreaRatioRef = useRef(paneAreaRatio); const paneAreaRatioRef = useRef(paneAreaRatio);
paneAreaRatioRef.current = paneAreaRatio; paneAreaRatioRef.current = paneAreaRatio;
/** 사용자 드래그로 설정한 pane 비율 — prop 변경 시 초기화 */
const userDraggedRatioRef = useRef<{ candle: number; aux: number } | null>(null);
const lastCrosshairPriceRef = useRef<number | null>(null); const lastCrosshairPriceRef = useRef<number | null>(null);
const onTradeOrderRequestRef = useRef(onTradeOrderRequest); const onTradeOrderRequestRef = useRef(onTradeOrderRequest);
onTradeOrderRequestRef.current = onTradeOrderRequest; onTradeOrderRequestRef.current = onTradeOrderRequest;
@@ -276,6 +278,13 @@ const TradingChart: React.FC<TradingChartProps> = ({
const indicatorSyncInFlightRef = useRef(false); const indicatorSyncInFlightRef = useRef(false);
const [paneLegendPaused, setPaneLegendPaused] = useState(false); const [paneLegendPaused, setPaneLegendPaused] = useState(false);
const [chartBodyHeight, setChartBodyHeight] = useState(0); const [chartBodyHeight, setChartBodyHeight] = useState(0);
/** 캔들 pane 하단 Y 위치 (드래그 핸들용) — null 이면 보조지표 없음 */
const [candlePaneBottomY, setCandlePaneBottomY] = useState<number | null>(null);
const paneResizeDragRef = useRef<{
startClientY: number;
startCandleH: number;
wrapperH: number;
} | null>(null);
const pendingIndicatorResyncRef = useRef(false); const pendingIndicatorResyncRef = useRef(false);
const indicatorReloadGenRef = useRef(0); const indicatorReloadGenRef = useRef(0);
const prevMarket = useRef<string>(market); const prevMarket = useRef<string>(market);
@@ -324,6 +333,74 @@ const TradingChart: React.FC<TradingChartProps> = ({
return () => ro.disconnect(); return () => ro.disconnect();
}, [chartMgr]); }, [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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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(() => { const toggleCandleOnly = useCallback(() => {
if (candleOnlyLocked) return; if (candleOnlyLocked) return;
setCandleOnlyMode(v => !v); setCandleOnlyMode(v => !v);
@@ -410,7 +487,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
if (mgr.isCandleOnlyLayout()) { if (mgr.isCandleOnlyLayout()) {
mgr.restoreFromCandleFullscreen(wrapperH); mgr.restoreFromCandleFullscreen(wrapperH);
} }
const ratio = paneAreaRatioRef.current; // 사용자 드래그 비율 우선, 없으면 prop 기반 비율
const ratio = userDraggedRatioRef.current ?? paneAreaRatioRef.current;
if (ratio && ratio.aux > 0) { if (ratio && ratio.aux > 0) {
mgr.setPaneAreaRatio(ratio.candle, ratio.aux); mgr.setPaneAreaRatio(ratio.candle, ratio.aux);
} else { } else {
@@ -443,6 +521,11 @@ const TradingChart: React.FC<TradingChartProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [paneLayoutClamp, paneAreaRatio]); }, [paneLayoutClamp, paneAreaRatio]);
/** paneAreaRatio prop 이 외부에서 변경되면 드래그 비율 초기화 (전략 변경 등) */
useEffect(() => {
userDraggedRatioRef.current = null;
}, [paneAreaRatio]);
/** pane 레이아웃 + 가격·시간축 정상화 (느린 네트워크·레이아웃 지연 후 복구용) */ /** pane 레이아웃 + 가격·시간축 정상화 (느린 네트워크·레이아웃 지연 후 복구용) */
const normalizeChartViewport = useCallback((mgr: ChartManager) => { const normalizeChartViewport = useCallback((mgr: ChartManager) => {
applyPaneLayout(mgr); applyPaneLayout(mgr);
@@ -1614,6 +1697,17 @@ const TradingChart: React.FC<TradingChartProps> = ({
ref={containerRef} ref={containerRef}
className="chart-container" className="chart-container"
/> />
{/* 캔들 pane 하단 드래그 핸들 — 캔들/보조지표 높이 비율 조절 */}
{candlePaneBottomY != null && !candleOnlyMode && !paneLayoutClamp && chartPaintReady && (
<div
className="chart-pane-resize-handle"
style={{ top: candlePaneBottomY }}
onPointerDown={onPaneResizePointerDown}
onPointerMove={onPaneResizePointerMove}
onPointerUp={onPaneResizePointerUp}
onPointerCancel={onPaneResizePointerUp}
/>
)}
{chartMgr && !candleOnlyMode && showPaneLegend && chartPaintReady && ( {chartMgr && !candleOnlyMode && showPaneLegend && chartPaintReady && (
<ChartRightToolbar <ChartRightToolbar
manager={chartMgr} manager={chartMgr}
@@ -35,7 +35,6 @@ import {
resolveStrategyPrimaryTimeframe, resolveStrategyPrimaryTimeframe,
} from '../../utils/strategyToChartIndicators'; } from '../../utils/strategyToChartIndicators';
import type { ChartOverlayToggleKey } from '../../utils/chartOverlayVisibility'; import type { ChartOverlayToggleKey } from '../../utils/chartOverlayVisibility';
import StrategyOscillatorPanes from './StrategyOscillatorPanes';
interface Props { interface Props {
symbol: string; symbol: string;
@@ -73,6 +72,8 @@ function newIndId() {
return `btd_${_indSeq}_${Date.now()}`; return `btd_${_indSeq}_${Date.now()}`;
} }
const ALL_TF_VISIBLE = { '1m': true, '3m': true, '5m': true, '10m': true, '15m': true, '30m': true, '1h': true, '4h': true, '1D': true, '1W': true, '1M': true };
function defaultOverlayIndicators( function defaultOverlayIndicators(
getParams: (t: string, d?: Record<string, number | string | boolean>) => Record<string, number | string | boolean>, getParams: (t: string, d?: Record<string, number | string | boolean>) => Record<string, number | string | boolean>,
): IndicatorConfig[] { ): IndicatorConfig[] {
@@ -89,11 +90,32 @@ function defaultOverlayIndicators(
params, params,
plots: def.plots, plots: def.plots,
hlines: def.hlines ?? [], 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]; return [cfg];
} }
/** 전략 미선택 시 기본 오실레이터(RSI, MACD)를 TradingChart sub-pane 으로 추가 */
function defaultOscillatorIndicators(
getParams: (t: string, d?: Record<string, number | string | boolean>) => Record<string, number | string | boolean>,
): IndicatorConfig[] {
const result: IndicatorConfig[] = [];
for (const type of ['RSI', 'MACD']) {
const def = getIndicatorDef(type);
if (!def) continue;
const params: Record<string, number | string | boolean> = { ...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; const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true;
/** /**
@@ -156,9 +178,11 @@ const BacktestAnalysisChart: React.FC<Props> = ({
}, [strategyId]); }, [strategyId]);
/** /**
* overlayVisibility 가 없는 경우(BacktestHistoryPage 컨텍스트)에는 * 백테스트 화면(overlayVisibility 없음)에서 보조지표를 TradingChart sub-pane 으로 통합.
* 보조지표(오실레이터)TradingChart sub-pane 이 아닌 아래쪽 * - 기존: 오실레이터를 SVG 기반 StrategyOscillatorPanes 에서 별도 렌더링
* StrategyOscillatorPanes 섹션에서 표시한다. * → 캔들 차트와 시간 축(x축) 불일치, 우측 가격 라벨 없음
* - 변경: 모든 지표를 TradingChart sub-pane 으로 통합
* → 시간 축 완전 동기화, 우측 가격 라벨 표시
*/ */
const showOscillatorPanel = !overlayVisibility; const showOscillatorPanel = !overlayVisibility;
@@ -166,13 +190,15 @@ const BacktestAnalysisChart: React.FC<Props> = ({
let inds: IndicatorConfig[]; let inds: IndicatorConfig[];
if (strategy) { if (strategy) {
inds = buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig); inds = buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig);
// 오실레이터 패널을 별도 섹션으로 표시할 때는 TradingChart 에서 제거 // [수정] 오실레이터 필터 제거 — TradingChart sub-pane 에서 직접 렌더링
if (showOscillatorPanel) { // (기존: showOscillatorPanel 시 overlay 지표만 남기고 나머지는 SVG 패널로 분리)
inds = inds.filter(i => isOverlayType(i.type));
}
inds = inds.filter(i => i.timeframeVisibility?.[chartTimeframe] !== false); inds = inds.filter(i => i.timeframeVisibility?.[chartTimeframe] !== false);
} else { } else {
inds = defaultOverlayIndicators(getParams); inds = defaultOverlayIndicators(getParams);
// 전략 미선택 시 기본 오실레이터(RSI, MACD)도 TradingChart sub-pane 으로 추가
if (showOscillatorPanel) {
inds = [...inds, ...defaultOscillatorIndicators(getParams)];
}
} }
if (overlayVisibility) { if (overlayVisibility) {
inds = ensurePaperChartOverlays(inds, getParams, getVisualConfig); inds = ensurePaperChartOverlays(inds, getParams, getVisualConfig);
@@ -467,9 +493,7 @@ const BacktestAnalysisChart: React.FC<Props> = ({
<div className="btd-analysis-error"> .</div> <div className="btd-analysis-error"> .</div>
)} )}
</div> </div>
{showOscillatorPanel && !loading && bars.length > 0 && ( {/* 오실레이터 pane 은 TradingChart 내부 sub-pane 으로 통합 (시간 축 동기화) */}
<StrategyOscillatorPanes bars={bars} strategy={strategy} />
)}
</div> </div>
</div> </div>
); );
+11 -6
View File
@@ -4045,9 +4045,10 @@ export class ChartManager {
private _minIndPx(H: number): number { private _minIndPx(H: number): number {
if (this._compactPaneLayout) { 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( private _paneHeightFractions(
@@ -4061,12 +4062,16 @@ export class ChartManager {
} }
if (this._compactPaneLayout && N === 1 && availableHeight != null && availableHeight < 400) { if (this._compactPaneLayout && N === 1 && availableHeight != null && availableHeight < 400) {
const mainFrac = 0.52; 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 }; return { mainFrac, eachIndFrac: (1 - mainFrac - volFrac) / N };
} }