실시간 차트 캔들차트 높이 조절
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -250,6 +250,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
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<number | null>(null);
|
||||
const onTradeOrderRequestRef = useRef(onTradeOrderRequest);
|
||||
onTradeOrderRequestRef.current = onTradeOrderRequest;
|
||||
@@ -276,6 +278,13 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
const indicatorSyncInFlightRef = useRef(false);
|
||||
const [paneLegendPaused, setPaneLegendPaused] = useState(false);
|
||||
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 indicatorReloadGenRef = useRef(0);
|
||||
const prevMarket = useRef<string>(market);
|
||||
@@ -324,6 +333,74 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
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<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(() => {
|
||||
if (candleOnlyLocked) return;
|
||||
setCandleOnlyMode(v => !v);
|
||||
@@ -410,7 +487,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
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<TradingChartProps> = ({
|
||||
// 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<TradingChartProps> = ({
|
||||
ref={containerRef}
|
||||
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 && (
|
||||
<ChartRightToolbar
|
||||
manager={chartMgr}
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
resolveStrategyPrimaryTimeframe,
|
||||
} from '../../utils/strategyToChartIndicators';
|
||||
import type { ChartOverlayToggleKey } from '../../utils/chartOverlayVisibility';
|
||||
import StrategyOscillatorPanes from './StrategyOscillatorPanes';
|
||||
|
||||
interface Props {
|
||||
symbol: string;
|
||||
@@ -73,6 +72,8 @@ function newIndId() {
|
||||
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(
|
||||
getParams: (t: string, d?: Record<string, number | string | boolean>) => Record<string, number | string | boolean>,
|
||||
): 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<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;
|
||||
|
||||
/**
|
||||
@@ -156,9 +178,11 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
}, [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<Props> = ({
|
||||
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<Props> = ({
|
||||
<div className="btd-analysis-error">표시할 캔들 데이터가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
{showOscillatorPanel && !loading && bars.length > 0 && (
|
||||
<StrategyOscillatorPanes bars={bars} strategy={strategy} />
|
||||
)}
|
||||
{/* 오실레이터 pane 은 TradingChart 내부 sub-pane 으로 통합 (시간 축 동기화) */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user