Files
goldenChart/frontend/src/components/TradingChart.tsx
T
2026-06-13 02:33:17 +09:00

2109 lines
83 KiB
TypeScript

import React, { useRef, useEffect, useLayoutEffect, useState, useCallback, memo } from 'react';
import type { MouseEventParams, Time } from 'lightweight-charts';
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, LegendData, Drawing, ChartMode, Timeframe } from '../types';
import { ChartManager } from '../utils/ChartManager';
import { formatUpbitKrwPrice } from '../utils/safeFormat';
import { useChartTimeFormat } from '../utils/chartTimeFormat';
import { setIndicatorChartContext } from '../utils/indicatorRegistry';
import { DISPLAY_COUNT } from '../hooks/useUpbitData';
/** Upbit 실시간 — 초기 히스토리가 이 개수 미만이면 setData 하지 않음 (종목 전환 직후 sparse 봉 → 캔들 폭 비정상) */
const MIN_BARS_FOR_FULL_RELOAD = Math.min(50, Math.floor(DISPLAY_COUNT / 4));
/** pane 비율·가격축 계산에 쓸 수 있는 최소 래퍼 높이 (원격·모바일 레이아웃 지연 대응) */
const MIN_VALID_LAYOUT_HEIGHT = 80;
const LAYOUT_RETRY_MAX = 30;
function createIndicatorReloadCover(containerEl: HTMLDivElement | null): HTMLDivElement | null {
if (!containerEl?.parentElement) return null;
const cover = document.createElement('div');
cover.className = 'chart-loading chart-indicator-reload-cover';
const spinner = document.createElement('div');
spinner.className = 'loading-spinner';
spinner.setAttribute('role', 'status');
spinner.setAttribute('aria-label', '지표 로딩 중');
cover.appendChild(spinner);
containerEl.parentElement.appendChild(cover);
return cover;
}
function fadeOutIndicatorReloadCover(cover: HTMLDivElement | null): void {
if (!cover) return;
requestAnimationFrame(() => requestAnimationFrame(() => {
cover.style.transition = 'opacity 0.25s ease';
cover.style.opacity = '0';
setTimeout(() => { cover.remove(); }, 300);
}));
}
/** 종목 전환 직후 stale/partial 데이터 차단 — barsMarket 확정 후 렌더 허용 */
function canApplyChartBars(
barData: OHLCVBar[],
barsMarket: string | null | undefined,
market: string,
): boolean {
if (barData.length < 2) return false;
if (barsMarket === undefined) return true;
if (barsMarket == null || barsMarket !== market) return false;
if (!market.startsWith('KRW-')) return true;
if (barData.length >= MIN_BARS_FOR_FULL_RELOAD) return true;
return true;
}
import DrawingCanvas, { hitTestDrawing } from './DrawingCanvas';
import PaneLegend, { type PaneLegendProps } from './PaneLegend';
import ChartRightToolbar, { type CandleOverlayToggleItem } from './ChartRightToolbar';
import CandlePaneTimeAxis from './CandlePaneTimeAxis';
import ChartHoverToolbar from './ChartHoverToolbar';
import ChartMagnifier from './ChartMagnifier';
import ChartContextMenu from './ChartContextMenu';
import CandlePaneControls from './CandlePaneControls';
import IndicatorPaneControls from './IndicatorPaneControls';
import { getKoreanName } from '../utils/marketNameCache';
import { DEFAULT_DISPLAY_TIMEZONE } from '../utils/timezone';
import { chartHasStaleIndicators, classifyIndicatorChartChange, singleIndParamKey } from '../utils/indicatorChartSync';
import { sortIndicatorsForPaneLoad } from '../utils/indicatorPaneMerge';
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
import PaneSeparatorOverlay from './PaneSeparatorOverlay';
import TradeSignalLabelOverlay from './TradeSignalLabelOverlay';
import { centerChartOnSignalBar } from '../utils/tradeSignalChartMarkers';
interface TradingChartProps {
bars: OHLCVBar[];
/** bars 가 현재 market 과 일치할 때만 전달 (없으면 bars.length>0 이면 준비된 것으로 간주) */
barsMarket?: string | null;
/** BB 등 다른 심볼 계산용 */
market?: string;
timeframe?: Timeframe;
chartType: ChartType;
theme: Theme;
mode: ChartMode;
indicators: IndicatorConfig[];
drawingTool: string;
drawings: Drawing[];
logScale: boolean;
drawingsLocked?: boolean;
drawingsVisible?: boolean;
onCrosshair: (data: LegendData | null) => void;
onManagerReady: (mgr: ChartManager) => void;
onAddDrawing: (d: Drawing) => void;
/** 시리즈 단일 클릭 → (indicatorId | '__main__' | null, 패인 좌측상단 screen 좌표) */
onSeriesClick?: (entryId: string | null, point: { x: number; y: number }) => void;
/** 시리즈 더블 클릭 → 설정 모달 직접 오픈용 (패인 좌측상단 screen 좌표 포함) */
onSeriesDoubleClick?: (entryId: string | null, point: { x: number; y: number }) => void;
/** pane 레전드 레이블 호버 */
onHoverPaneLegend?: (id: string, sx: number, sy: number) => void;
/** pane 레전드 레이블 이탈 */
onLeavePaneLegend?: () => void;
/** 멀티차트 전체 보기: 이 차트를 단일 모드로 확장 (있으면 hover toolbar 버튼에 연결) */
onFullView?: () => void;
/** 현재 선택된 드로잉 ID (핸들 렌더용) */
selectedDrawingId?: string | null;
/** cursor 모드에서 드로잉 단일 클릭 */
onDrawingClick?: (id: string | null, screenX: number, screenY: number) => void;
/** cursor 모드에서 드로잉 더블 클릭 */
onDrawingDoubleClick?: (id: string, screenX: number, screenY: number) => void;
/** 돋보기 활성 여부 */
magnifierEnabled?: boolean;
/** 돋보기 닫기 콜백 */
onMagnifierClose?: () => void;
/**
* 데이터 로드 완료 콜백 — reloadAll 이 setInitialVisibleRange 까지 마친 뒤 호출.
* ChartSlot 에서 멀티차트 첫 마운트 시 sync range 를 재적용하는 데 사용.
*/
onDataLoaded?: () => void;
/** setData 직후 캔들·볼륨 반영 완료 — 실시간 틱 허용 시점 (지표 로드 전) */
onCandlesReady?: () => void;
/** 보조지표 pane 순서 변경 (드래그 핸들): fromId 를 insertBeforeId 앞으로 이동 (null = 맨 뒤) */
onReorderIndicators?: (fromId: string, insertBeforeId: string | null) => void;
/** 보조지표 pane 병합 */
onMergeIndicators?: (fromId: string, intoId: string) => void;
/** 병합 pane 분리 */
onSplitIndicatorPane?: (hostId: string) => void;
/** 지표 단독 전체화면 확장 */
onExpandIndicator?: (id: string) => void;
/** 지표 제거 (X 버튼) */
onRemoveIndicator?: (id: string) => void;
/** 보조지표 pane 복사 */
onDuplicateIndicator?: (id: string) => void;
/** 현재 단독 전체화면 중인 지표 id */
focusedIndicatorId?: string | null;
/** 전체화면 → 전체 보기 복원 */
onRestoreIndicators?: () => void;
/** 지표 표시/숨기기 */
onToggleIndicatorHidden?: (id: string) => void;
/** 지표 설정 모달 열기 */
onOpenIndicatorSettings?: (id: string) => void;
/** 우클릭 메뉴에서 매수·매도 선택 시 */
onTradeOrderRequest?: (req: { market: string; price: number; side: 'buy' | 'sell' }) => void;
/** 표시 시간대 (IANA) */
displayTimezone?: string;
/** 거래량 pane 표시 (기본 true) */
volumeVisible?: boolean;
/** 하단 호버 줌·스크롤 툴바 (기본 true) */
showHoverToolbar?: boolean;
/** 캔들 영역 오버레이 지표 가격축 라벨·설명 (기본 true) */
candleAreaPriceLabelsEnabled?: boolean;
/** 하단 보조지표 pane 가격축 라벨·설명 (기본 true) */
indicatorAreaPriceLabelsEnabled?: boolean;
/** @deprecated candleArea + indicatorArea 동일 값 */
seriesPriceLabelsEnabled?: boolean;
/** 실시간 차트 화면 표시 여부 — false 이면 숨김 중 무거운 재로드 지연 */
chartVisible?: boolean;
/** pane(캔들·거래량·보조지표) 구분선 */
paneSeparatorOptions?: ChartPaneSeparatorOptions;
/** true: pane 합산 높이로 chart-container 를 키우지 않고 래퍼 높이에 맞춤 (미니 차트·알림 목록) */
paneLayoutClamp?: boolean;
/** paneLayoutClamp=true 환경에서도 캔들/보조지표 비율 드래그 핸들을 표시 (백테스팅 차트 등) */
showPaneResizeHandle?: boolean;
/** 캔들+거래량 vs 보조 pane 높이 비율 (aux 0 이면 미적용) */
paneAreaRatio?: { candle: number; aux: number } | null;
/** 보조지표 pane 레전드(이름·값 오버레이) 표시 (기본 true) */
showPaneLegend?: boolean;
/** pane 레전드와 별도 — 우측 드래그·설정 툴바 (미지정 시 showPaneLegend 와 동일) */
showChartRightToolbar?: boolean;
/** 캔들 pane 우측 상단 — 이동평균·일목·볼린저 on/off (미니 차트 등, showChartRightToolbar 없이도 가능) */
showCandleOverlayControls?: boolean;
/** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 (기본 true) */
crosshairInfoVisible?: boolean;
/** 외부 캔들 데이터 fetch 중 (실시간 차트 초기 로딩) */
dataLoading?: boolean;
/** chartPaintReady 변경 알림 (워크스페이스 전체 로딩 오버레이용) */
onChartPaintReadyChange?: (ready: boolean) => void;
/** 마운트 시 캔들 pane 전체보기(하단 보조지표 pane 숨김) — 가상매매 카드 등 */
defaultCandleOnly?: boolean;
/** 캔들 pane 좌하단 전체보기/복원 버튼 (기본 true) */
showCandlePaneControls?: boolean;
/** 캔들 오버레이(이동평균·일목·볼린저) 표시 토글 — 우측 툴바 상단 */
candleOverlayToggles?: CandleOverlayToggleItem[];
onToggleCandleOverlay?: (key: import('../utils/chartOverlayVisibility').ChartOverlayToggleKey) => void;
customOverlayActive?: boolean;
onCustomOverlayActiveChange?: (active: boolean) => void;
onOpenCustomOverlaySettings?: () => void;
custom1OverlayActive?: boolean;
onCustom1OverlayActiveChange?: (active: boolean) => void;
onOpenCustom1OverlaySettings?: () => void;
/** 9·20일 신고가·신저가 라벨 표시 (실시간 차트 우측 툴바) */
priceExtremeLabelsVisible?: boolean;
onTogglePriceExtremeLabels?: () => void;
/** 미니 차트 — plot 너비에 맞는 초기 x축 캔들 수 (미지정 시 DISPLAY_COUNT) */
resolveInitialDisplayCount?: (plotWidth: number) => number;
/** true — reload·layout 복구 타이머의 반복 viewport 재조정 생략 (미니 카드 깜빡임 방지) */
skipViewportRecovery?: boolean;
/** true — 캔들 숨김, 보조지표 pane 선형 그래프만 표시 (시그널 상세 하단 차트 등) */
auxIndicatorOnly?: boolean;
/** 지정 시 초기 visible range 대신 시그널 봉 시간축 중앙 정렬 */
centerOnSignalTime?: number;
/** centerOnSignalTime 사용 시 화면에 보일 봉 수 (미지정 시 전체) */
centerVisibleBarCount?: number;
}
const TradingChart: React.FC<TradingChartProps> = ({
bars, barsMarket, market = '', timeframe = '1D', chartType, theme, mode, indicators, drawingTool, drawings,
logScale, drawingsLocked = false, drawingsVisible = true,
onCrosshair, onManagerReady, onAddDrawing,
onSeriesClick, onSeriesDoubleClick,
onHoverPaneLegend, onLeavePaneLegend,
onFullView,
selectedDrawingId,
onDrawingClick,
onDrawingDoubleClick,
magnifierEnabled = false,
onMagnifierClose,
onDataLoaded,
onCandlesReady,
onReorderIndicators,
onMergeIndicators,
onSplitIndicatorPane,
onExpandIndicator,
onRemoveIndicator,
onDuplicateIndicator,
focusedIndicatorId,
onRestoreIndicators,
onToggleIndicatorHidden,
onOpenIndicatorSettings,
onTradeOrderRequest,
displayTimezone = DEFAULT_DISPLAY_TIMEZONE,
volumeVisible = true,
showHoverToolbar = true,
candleAreaPriceLabelsEnabled,
indicatorAreaPriceLabelsEnabled,
seriesPriceLabelsEnabled,
chartVisible = true,
paneSeparatorOptions,
paneLayoutClamp = false,
showPaneResizeHandle = false,
paneAreaRatio = null,
showPaneLegend = true,
showChartRightToolbar,
showCandleOverlayControls = false,
crosshairInfoVisible = true,
dataLoading = false,
onChartPaintReadyChange,
defaultCandleOnly = false,
showCandlePaneControls = true,
candleOverlayToggles,
onToggleCandleOverlay,
customOverlayActive,
onCustomOverlayActiveChange,
onOpenCustomOverlaySettings,
custom1OverlayActive,
onCustom1OverlayActiveChange,
onOpenCustom1OverlaySettings,
priceExtremeLabelsVisible,
onTogglePriceExtremeLabels,
resolveInitialDisplayCount,
skipViewportRecovery = false,
auxIndicatorOnly = false,
centerOnSignalTime,
centerVisibleBarCount,
}) => {
const effectiveShowChartRightToolbar = showChartRightToolbar ?? showPaneLegend;
const showOverlayControlsOnly = !!(
showCandleOverlayControls
&& candleOverlayToggles?.length
&& onToggleCandleOverlay
&& !effectiveShowChartRightToolbar
);
const containerRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
const managerRef = useRef<ChartManager | null>(null);
const barsRef = useRef<OHLCVBar[]>([]);
// 드로잉 최신 참조 (캡처 리스너에서 closure 없이 접근)
const drawingsRef = useRef<Drawing[]>(drawings);
const drawingToolRef = useRef<string>(drawingTool);
const drawingsVisibleRef = useRef<boolean>(drawingsVisible);
const drawingsLockedRef = useRef<boolean>(drawingsLocked);
const modeRef = useRef<ChartMode>(mode);
const onDrawingClickRef = useRef(onDrawingClick);
const onDrawingDoubleClickRef = useRef(onDrawingDoubleClick);
const canPanRef = useRef(false);
const panStateRef = useRef({
active: false,
lastX: 0,
lastY: 0,
moved: false,
/** 0=캔들 pane — 세로 패닝 허용 */
originPaneIndex: 0,
});
const suppressClickRef = useRef(false);
const seriesDblSuppressRef = useRef(false);
const toggleCandleOnlyRef = useRef<() => void>(() => {});
const candleOnlyModeRef = useRef(false);
const paneAreaRatioRef = useRef(paneAreaRatio);
paneAreaRatioRef.current = paneAreaRatio;
const paneLayoutClampRef = useRef(paneLayoutClamp);
paneLayoutClampRef.current = paneLayoutClamp;
/** 사용자 드래그로 설정한 pane 비율 — prop 변경 시 초기화 */
const userDraggedRatioRef = useRef<{ candle: number; aux: number } | null>(null);
const lastCrosshairPriceRef = useRef<number | null>(null);
const onTradeOrderRequestRef = useRef(onTradeOrderRequest);
onTradeOrderRequestRef.current = onTradeOrderRequest;
const onSeriesDoubleClickRef = useRef(onSeriesDoubleClick);
onSeriesDoubleClickRef.current = onSeriesDoubleClick;
const marketRef = useRef(market);
marketRef.current = market;
const barsMarketRef = useRef(barsMarket);
barsMarketRef.current = barsMarket;
const [ctxMenu, setCtxMenu] = useState<{
x: number; y: number; price: number;
} | null>(null);
// 드로잉 더블클릭 감지용
const lastDrawingClickRef = useRef({ id: '', time: 0 });
const drawingSingleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 직전 값 추적 (불필요한 재실행 방지)
const prevBarsKey = useRef<string>('');
const prevIndKey = useRef<string>('');
const prevSortedPKRef = useRef<string>(''); // 순서 무관 paramKey (reorder 감지용)
const prevIndicatorsListRef = useRef<IndicatorConfig[]>(indicators);
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);
const prevMarketTf = useRef<Timeframe>(timeframe);
const lastAppliedMarketRef = useRef<string>('');
const prevChartType = useRef<ChartType>(chartType);
const prevTheme = useRef<Theme>(theme);
const prevLogScale = useRef<boolean>(logScale);
const indicatorsRef = useRef(indicators);
const recoveryTimerRef = useRef<number | null>(null);
const resizeDebounceRef = useRef<number | null>(null);
const chartVisibleRef = useRef(chartVisible);
const resolveInitialDisplayCountRef = useRef(resolveInitialDisplayCount);
const skipViewportRecoveryRef = useRef(skipViewportRecovery);
const auxIndicatorOnlyRef = useRef(auxIndicatorOnly);
const centerOnSignalTimeRef = useRef(centerOnSignalTime);
const centerVisibleBarCountRef = useRef(centerVisibleBarCount);
const lastViewportFitRef = useRef<{ count: number; width: number } | null>(null);
useEffect(() => {
resolveInitialDisplayCountRef.current = resolveInitialDisplayCount;
}, [resolveInitialDisplayCount]);
useEffect(() => {
skipViewportRecoveryRef.current = skipViewportRecovery;
}, [skipViewportRecovery]);
useEffect(() => {
auxIndicatorOnlyRef.current = auxIndicatorOnly;
}, [auxIndicatorOnly]);
useEffect(() => {
centerOnSignalTimeRef.current = centerOnSignalTime;
}, [centerOnSignalTime]);
useEffect(() => {
centerVisibleBarCountRef.current = centerVisibleBarCount;
}, [centerVisibleBarCount]);
const applySnapshotViewport = useCallback((mgr: ChartManager) => {
const t = centerOnSignalTimeRef.current;
if (t != null && Number.isFinite(t)) {
centerChartOnSignalBar(mgr, t, centerVisibleBarCountRef.current);
} else {
mgr.fitContent();
}
}, []);
const chartTimeFormat = useChartTimeFormat();
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
/** 초기 reloadAll 완료 전 — pane 구분선·부분 레이아웃 노출 방지 */
const [chartPaintReady, setChartPaintReady] = useState(false);
/** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */
const candleOnlyLocked = defaultCandleOnly;
const [candleOnlyMode, setCandleOnlyMode] = useState(defaultCandleOnly);
const paneDragRef = useRef<((id: string, e: React.PointerEvent) => void) | null>(null);
useEffect(() => {
if (barsMarket === market) {
barsRef.current = bars;
} else {
barsRef.current = [];
}
}, [bars, barsMarket, market]);
useEffect(() => {
indicatorsRef.current = indicators;
}, [indicators]);
useEffect(() => {
chartVisibleRef.current = chartVisible;
}, [chartVisible]);
useEffect(() => {
if (dataLoading) setChartPaintReady(false);
}, [dataLoading]);
useEffect(() => {
onChartPaintReadyChange?.(chartPaintReady && !dataLoading);
}, [chartPaintReady, dataLoading, onChartPaintReadyChange]);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const update = () => setChartBodyHeight(el.clientHeight);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
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);
// paneLayoutClamp 모드: 컨테이너를 래퍼 높이에 고정 (overflow:hidden 으로 클립)
// 일반 모드: 보조지표 필요 시 컨테이너 확장하여 스크롤 영역 확보
const newContainerH = paneLayoutClampRef.current
? H
: (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);
}, [candleOnlyLocked]);
useEffect(() => { toggleCandleOnlyRef.current = toggleCandleOnly; }, [toggleCandleOnly]);
useEffect(() => { candleOnlyModeRef.current = candleOnlyMode; }, [candleOnlyMode]);
const prevCandleOnlyRef = useRef(false);
// 최신 값을 ref에 동기화 (캡처 리스너 closure 탈출)
useEffect(() => {
setIndicatorChartContext(market, timeframe);
}, [market, timeframe]);
useEffect(() => { drawingsRef.current = drawings; }, [drawings]);
useEffect(() => { drawingToolRef.current = drawingTool; }, [drawingTool]);
useEffect(() => { drawingsVisibleRef.current = drawingsVisible; }, [drawingsVisible]);
useEffect(() => { drawingsLockedRef.current = drawingsLocked; }, [drawingsLocked]);
useEffect(() => { modeRef.current = mode; }, [mode]);
useEffect(() => { onDrawingClickRef.current = onDrawingClick; }, [onDrawingClick]);
useEffect(() => { onDrawingDoubleClickRef.current = onDrawingDoubleClick; }, [onDrawingDoubleClick]);
useEffect(() => {
canPanRef.current = drawingTool === 'cursor';
const el = containerRef.current;
if (el && !panStateRef.current.active) {
el.style.cursor = canPanRef.current ? 'grab' : '';
}
}, [mode, drawingTool]);
const volumeVisibleRef = useRef(volumeVisible);
useEffect(() => { volumeVisibleRef.current = volumeVisible; }, [volumeVisible]);
useEffect(() => {
const mgr = managerRef.current;
if (!mgr) return;
const wrapperH = wrapperRef.current?.clientHeight ?? 0;
mgr.setVolumeVisible(volumeVisible, wrapperH > 0 ? wrapperH : undefined);
}, [volumeVisible]);
const resolvedCandlePriceLabels = candleAreaPriceLabelsEnabled ?? seriesPriceLabelsEnabled ?? true;
const resolvedIndicatorPriceLabels = indicatorAreaPriceLabelsEnabled ?? seriesPriceLabelsEnabled ?? true;
useEffect(() => {
managerRef.current?.setCandleAreaPriceLabelsEnabled(resolvedCandlePriceLabels);
}, [resolvedCandlePriceLabels]);
useEffect(() => {
managerRef.current?.setIndicatorAreaPriceLabelsEnabled(resolvedIndicatorPriceLabels);
}, [resolvedIndicatorPriceLabels]);
useEffect(() => {
if (!paneSeparatorOptions) return;
managerRef.current?.setPaneSeparatorOptions(paneSeparatorOptions);
managerRef.current?.refreshPaneSeparatorOverlay();
}, [paneSeparatorOptions]);
/**
* pane 높이 재배분 + 스크롤 컨테이너 확장
*
* wrapper.clientHeight 를 정확한 가용 높이로 ChartManager 에 전달합니다.
* CSS Grid 레이아웃 초기화 지연으로 wrapperH=0 이 될 수 있으므로,
* retryCount 를 통해 최대 30회 자동 재시도합니다 (원격·테더링 등 느린 레이아웃 대응).
*/
const applyPaneLayout = useCallback((mgr: ChartManager, retryCount = 0) => {
const wrapper = wrapperRef.current;
const container = containerRef.current;
if (!wrapper || !container) return;
const wrapperH = wrapper.clientHeight;
if (wrapperH < MIN_VALID_LAYOUT_HEIGHT) {
if (retryCount < LAYOUT_RETRY_MAX) {
const delay = Math.min(120 * (retryCount + 1), 500);
setTimeout(() => applyPaneLayout(mgr, retryCount + 1), delay);
}
return;
}
const candleOnly = candleOnlyModeRef.current;
let required: number;
if (candleOnly) {
mgr.applyCandleOnlyLayout(true, wrapperH);
required = wrapperH;
} else {
if (auxIndicatorOnlyRef.current) {
mgr.applyAuxIndicatorOnlyLayout(true);
} else if (mgr.isCandleOnlyLayout()) {
mgr.restoreFromCandleFullscreen(wrapperH);
}
// 사용자 드래그 비율 우선, 없으면 prop 기반 비율
const ratio = userDraggedRatioRef.current ?? paneAreaRatioRef.current;
if (ratio && ratio.aux > 0) {
mgr.setPaneAreaRatio(ratio.candle, ratio.aux);
} else {
mgr.setPaneAreaRatio(0, 0);
}
required = mgr.resetPaneHeights(wrapperH);
}
// LWC는 chart-container에 마운트 — 우측 툴바(44px) 제외 너비로 맞춰 가격 라벨이 가리지 않음
const plotW = Math.max(0, container.clientWidth);
if (candleOnly || paneLayoutClamp) {
// 전체보기·미니차트: 래퍼 높이에 고정 resize (autoSize 피드백 루프·무한 스크롤 방지)
container.style.height = `${wrapperH}px`;
try {
if (plotW > 0 && wrapperH > 0) mgr.resize(plotW, wrapperH);
} catch { /* ok */ }
if (candleOnly) wrapper.scrollTop = 0;
} else if (required > wrapperH + 4) {
// 보조지표 있음: 컨테이너를 required 크기로 확장해 스크롤 영역 확보
container.style.height = `${required}px`;
} else {
// 기본 상태: 항상 명시적 픽셀 높이를 부여 (height:'' → CSS height:100% → auto 해석 문제 방지)
// autoSize:true LWC가 container.contentRect.height를 읽어 정확히 wrapperH 에 맞게 렌더링
container.style.height = `${wrapperH}px`;
try {
if (plotW > 0 && wrapperH > 0) mgr.resize(plotW, wrapperH);
} catch { /* ok */ }
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paneLayoutClamp, paneAreaRatio]);
/** paneAreaRatio prop 이 외부에서 변경되면 드래그 비율 초기화 (전략 변경 등) */
useEffect(() => {
userDraggedRatioRef.current = null;
}, [paneAreaRatio]);
const getInitialDisplayCount = useCallback((): number => {
const plotW = containerRef.current?.clientWidth ?? wrapperRef.current?.clientWidth ?? 0;
const resolver = resolveInitialDisplayCountRef.current;
return resolver ? resolver(plotW) : DISPLAY_COUNT;
}, []);
const applyInitialVisibleRange = useCallback((mgr: ChartManager) => {
const plotW = containerRef.current?.clientWidth ?? wrapperRef.current?.clientWidth ?? 0;
if (plotW <= 0) return;
const count = getInitialDisplayCount();
const prev = lastViewportFitRef.current;
if (prev && prev.count === count && Math.abs(prev.width - plotW) < 4) return;
lastViewportFitRef.current = { count, width: plotW };
const fb = mgr.hasIchimoku() ? 28 : 0;
mgr.setInitialVisibleRange(count, fb);
}, [getInitialDisplayCount]);
/** pane 레이아웃 + 가격·시간축 정상화 (느린 네트워크·레이아웃 지연 후 복구용) */
const normalizeChartViewport = useCallback((mgr: ChartManager) => {
applyPaneLayout(mgr);
requestAnimationFrame(() => {
try { mgr.autoScale(); } catch { /* ok */ }
if (centerOnSignalTimeRef.current != null && Number.isFinite(centerOnSignalTimeRef.current)) {
applySnapshotViewport(mgr);
} else {
applyInitialVisibleRange(mgr);
}
});
}, [applyPaneLayout, applyInitialVisibleRange, applySnapshotViewport]);
/** 캔들 확대 보기 → 원복 직후 가격·시간축·pane 비율 정상화 */
useEffect(() => {
const mgr = managerRef.current;
if (!mgr || !chartMgr) return;
const wasOnly = prevCandleOnlyRef.current;
prevCandleOnlyRef.current = candleOnlyMode;
if (!wasOnly && candleOnlyMode) {
applyPaneLayout(mgr);
} else if (wasOnly && !candleOnlyMode && mgr.hasMainSeries()) {
const runRestore = () => {
const m = managerRef.current;
if (!m?.hasMainSeries()) return;
const wrapperH = wrapperRef.current?.clientHeight ?? 0;
m.restoreFromCandleFullscreen(wrapperH);
applyPaneLayout(m);
applyInitialVisibleRange(m);
};
requestAnimationFrame(() => requestAnimationFrame(runRestore));
[200, 500, 1000].forEach(delay => setTimeout(runRestore, delay));
}
}, [candleOnlyMode, chartMgr, applyPaneLayout, applyInitialVisibleRange]);
// ── 전체 재로드 (데이터 + 인디케이터) ─────────────────────────────────────
const reloadSafetyTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
const reloadGenRef = useRef(0);
const reloadInFlightRef = useRef(false);
const reloadCoalesceRef = useRef(false);
const reloadRetryRef = useRef(0);
const reloadAllRef = useRef<(
mgr: ChartManager,
newBars: OHLCVBar[],
ct: ChartType,
th: Theme,
ls: boolean,
inds: IndicatorConfig[],
) => Promise<void>>(async () => {});
const syncIndicatorTrackingRefs = useCallback((inds: IndicatorConfig[]) => {
prevIndKey.current = indKey(inds);
prevSortedPKRef.current = sortedParamKey(inds);
prevIndicatorsListRef.current = inds;
}, []);
const restoreLogicalRange = useCallback((
mgr: ChartManager,
savedLR: { from: number; to: number } | null,
) => {
if (!savedLR) return;
requestAnimationFrame(() => {
mgr.applyVisibleLogicalRange(savedLR.from, savedLR.to);
});
}, []);
const afterIndicatorPaneMutation = useCallback((mgr: ChartManager) => {
if (candleOnlyModeRef.current) {
mgr.applyCandleOnlyLayout(true, wrapperRef.current?.clientHeight);
} else if (auxIndicatorOnlyRef.current) {
mgr.applyAuxIndicatorOnlyLayout(true);
} else if (mgr.isCandleOnlyLayout()) {
mgr.restoreFromCandleFullscreen(wrapperRef.current?.clientHeight);
}
applyPaneLayout(mgr);
}, [applyPaneLayout]);
/** 누락된 지표만 추가 (전체 reloadIndicatorsOnly 회피) */
const repairMissingIndicators = useCallback(async (
mgr: ChartManager,
inds: IndicatorConfig[],
) => {
const loadedIds = mgr.getLoadedIndicatorIds();
const missing = inds.filter(i => i.hidden !== true && !loadedIds.has(i.id));
if (missing.length === 0) return;
indicatorSyncInFlightRef.current = true;
try {
await mgr.addIndicatorsBatch(missing);
afterIndicatorPaneMutation(mgr);
mgr.notifyPaneLayoutChanged();
requestAnimationFrame(() => requestAnimationFrame(() => {
afterIndicatorPaneMutation(mgr);
mgr.notifyPaneLayoutChanged();
}));
} finally {
indicatorSyncInFlightRef.current = false;
}
}, [afterIndicatorPaneMutation]);
/** 지표만 재로드 — 커버 + 스크롤 위치 복원으로 깜빡임·떨림 완화 */
const reloadIndicatorsWithCover = useCallback((
mgr: ChartManager,
inds: IndicatorConfig[],
onComplete?: () => void,
) => {
const cover = createIndicatorReloadCover(containerRef.current);
const savedLR = mgr.getVisibleLogicalRange();
const reloadGen = ++indicatorReloadGenRef.current;
indicatorSyncInFlightRef.current = true;
void mgr.reloadIndicatorsOnly(inds).then(() => {
if (reloadGen !== indicatorReloadGenRef.current) {
fadeOutIndicatorReloadCover(cover);
return;
}
afterIndicatorPaneMutation(mgr);
// resetPaneHeights 직후 레이블 좌표 재동기화 (탭 교체 후 어긋남 방지)
mgr.notifyPaneLayoutChanged();
requestAnimationFrame(() => requestAnimationFrame(() => {
if (reloadGen !== indicatorReloadGenRef.current) {
fadeOutIndicatorReloadCover(cover);
return;
}
restoreLogicalRange(mgr, savedLR);
fadeOutIndicatorReloadCover(cover);
indicatorSyncInFlightRef.current = false;
mgr.notifyPaneLayoutChanged();
[50, 150, 350, 700, 1200].forEach(ms => {
setTimeout(() => {
if (reloadGen === indicatorReloadGenRef.current) {
mgr.notifyPaneLayoutChanged();
}
}, ms);
});
onComplete?.();
}));
});
}, [afterIndicatorPaneMutation, restoreLogicalRange]);
/** 설정 저장 등으로 연속 갱신이 겹칠 때 마지막 상태로 한 번 더 동기화 */
const flushPendingIndicatorResync = useCallback((
mgr: ChartManager,
completedGen: number,
) => {
if (!pendingIndicatorResyncRef.current) return;
if (completedGen !== indicatorReloadGenRef.current) return;
pendingIndicatorResyncRef.current = false;
const latest = indicatorsRef.current;
indicatorSyncInFlightRef.current = true;
const flushGen = ++indicatorReloadGenRef.current;
reloadIndicatorsWithCover(mgr, latest, () => {
if (flushGen !== indicatorReloadGenRef.current) {
indicatorSyncInFlightRef.current = false;
return;
}
syncIndicatorTrackingRefs(latest);
indicatorSyncInFlightRef.current = false;
});
}, [reloadIndicatorsWithCover, syncIndicatorTrackingRefs]);
const queueFullReload = useCallback(() => {
if (!chartVisibleRef.current) return;
if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current);
recoveryTimerRef.current = window.setTimeout(() => {
recoveryTimerRef.current = null;
const m = managerRef.current;
const barData = barsRef.current;
const inds = indicatorsRef.current;
if (!m || barData.length === 0) return;
if (!canApplyChartBars(barData, barsMarket, market)) return;
prevBarsKey.current = '';
reloadAllRef.current?.(
m, barData,
prevChartType.current, prevTheme.current, prevLogScale.current,
inds,
);
}, 80);
}, [market, barsMarket]);
const queueIndicatorRecovery = useCallback(() => {
if (!chartVisibleRef.current) return;
if (reloadInFlightRef.current) return;
if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current);
recoveryTimerRef.current = window.setTimeout(() => {
recoveryTimerRef.current = null;
const m = managerRef.current;
const barData = barsRef.current;
const inds = indicatorsRef.current;
if (!m || barData.length === 0) return;
if (!canApplyChartBars(barData, barsMarket, market)) return;
const expected = countExpectedVisibleIndicators(inds);
if (expected === 0) return;
const loadedIds = m.getLoadedIndicatorIds();
if (chartHasStaleIndicators(loadedIds, inds)) {
reloadIndicatorsWithCover(m, inds, () => {
prevBarsKey.current = barsKey(barData, market);
syncIndicatorTrackingRefs(inds);
applyPaneLayout(m);
});
return;
}
if (loadedIds.size >= expected) return;
prevBarsKey.current = '';
const ct = prevChartType.current;
const th = prevTheme.current;
const ls = prevLogScale.current;
if (m.hasMainSeries()) {
void repairMissingIndicators(m, inds).then(() => {
if (
m.getLoadedIndicatorCount() >= expected
&& !chartHasStaleIndicators(m.getLoadedIndicatorIds(), inds)
) {
prevBarsKey.current = barsKey(barData, market);
syncIndicatorTrackingRefs(inds);
applyPaneLayout(m);
return;
}
reloadAllRef.current?.(m, barData, ct, th, ls, inds);
});
return;
}
reloadAllRef.current?.(m, barData, ct, th, ls, inds);
}, 120);
}, [market, barsMarket, applyPaneLayout, repairMissingIndicators, reloadIndicatorsWithCover, syncIndicatorTrackingRefs]);
/** setData 직후 캔들·볼륨 pane 레이아웃 확정 (지표 로드 중단 시에도 빈 화면 방지) */
const commitCandleLayout = useCallback((mgr: ChartManager) => {
requestAnimationFrame(() => {
applyPaneLayout(mgr);
requestAnimationFrame(() => {
applyInitialVisibleRange(mgr);
});
});
}, [applyPaneLayout, applyInitialVisibleRange]);
const reloadAll = useCallback(async (
mgr: ChartManager,
newBars: OHLCVBar[],
ct: ChartType,
th: Theme,
ls: boolean,
inds: IndicatorConfig[],
) => {
if (newBars.length === 0) return;
if (reloadInFlightRef.current) {
reloadCoalesceRef.current = true;
barsRef.current = newBars;
return;
}
barsRef.current = newBars;
reloadInFlightRef.current = true;
setChartPaintReady(false);
try {
do {
reloadCoalesceRef.current = false;
const gen = reloadGenRef.current;
const barData = barsRef.current.length >= 2 ? barsRef.current : newBars;
if (barData.length < 2) break;
reloadSafetyTimers.current.forEach(clearTimeout);
reloadSafetyTimers.current = [];
barsRef.current = barData;
mgr.setData(barData, ct, th);
if (gen !== reloadGenRef.current) {
prevBarsKey.current = '';
break;
}
mgr.setTheme(th);
mgr.setLogScale(ls);
mgr.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat);
lastAppliedMarketRef.current = market;
const sortedInds = sortIndicatorsForPaneLoad(inds);
if (sortedInds.length > 0) {
await mgr.addIndicatorsBatch(sortedInds);
}
if (gen !== reloadGenRef.current) {
prevBarsKey.current = '';
break;
}
const expected = countExpectedVisibleIndicators(inds);
const loaded = mgr.getLoadedIndicatorCount();
if (expected > 0 && loaded < expected) {
prevBarsKey.current = '';
if (reloadRetryRef.current < 4 && mgr.hasMainSeries()) {
reloadRetryRef.current += 1;
await mgr.reloadIndicatorsOnly(inds);
if (gen !== reloadGenRef.current) break;
}
if (mgr.getLoadedIndicatorCount() < expected) {
if (reloadRetryRef.current < 6) {
reloadRetryRef.current += 1;
queueIndicatorRecovery();
}
break;
}
}
reloadRetryRef.current = 0;
prevChartType.current = ct;
prevTheme.current = th;
prevLogScale.current = ls;
prevBarsKey.current = barsKey(barData, market);
syncIndicatorTrackingRefs(inds);
lastAppliedMarketRef.current = market;
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => {
normalizeChartViewport(mgr);
requestAnimationFrame(() => {
onCandlesReady?.();
setChartPaintReady(true);
resolve();
});
})));
if (gen !== reloadGenRef.current) {
prevBarsKey.current = '';
break;
}
onDataLoaded?.();
reloadSafetyTimers.current = [300, 700, 1400, 2500].map(delay =>
setTimeout(() => {
const m = managerRef.current;
if (!m || !m.hasMainSeries()) return;
const expectedLater = countExpectedVisibleIndicators(indicatorsRef.current);
if (expectedLater > 0 && m.getLoadedIndicatorCount() < expectedLater) {
prevBarsKey.current = '';
queueIndicatorRecovery();
return;
}
if (skipViewportRecoveryRef.current) return;
const w = wrapperRef.current;
if (!w || w.clientHeight < MIN_VALID_LAYOUT_HEIGHT) return;
normalizeChartViewport(m);
if (delay === 2500) onDataLoaded?.();
}, delay)
);
} while (reloadCoalesceRef.current);
} finally {
reloadInFlightRef.current = false;
if (reloadCoalesceRef.current) {
reloadCoalesceRef.current = false;
queueFullReload();
return;
}
const m = managerRef.current;
if (m?.hasMainSeries()) {
// 메인 캔들만 준비되어도 paint·실시간 틱 허용 (지표는 백그라운드 복구)
setChartPaintReady(true);
onCandlesReady?.();
}
}
}, [normalizeChartViewport, onCandlesReady, onDataLoaded, displayTimezone, timeframe, market, queueFullReload, queueIndicatorRecovery]);
useEffect(() => {
reloadAllRef.current = reloadAll;
}, [reloadAll]);
// ── 차트 초기화 (마운트 시 1회) ──────────────────────────────────────────
useEffect(() => {
if (!containerRef.current) return;
const mgr = new ChartManager(containerRef.current, theme, {
compactPaneLayout: paneLayoutClamp,
autoSize: !paneLayoutClamp,
volumePaneEnabled: volumeVisibleRef.current,
});
managerRef.current = mgr;
setChartMgr(mgr);
if (!volumeVisibleRef.current) {
const wrapperH = wrapperRef.current?.clientHeight ?? 0;
mgr.setVolumeVisible(false, wrapperH > 0 ? wrapperH : undefined);
}
mgr.setCandleAreaPriceLabelsEnabled(resolvedCandlePriceLabels);
mgr.setIndicatorAreaPriceLabelsEnabled(resolvedIndicatorPriceLabels);
if (paneSeparatorOptions) mgr.setPaneSeparatorOptions(paneSeparatorOptions);
if (auxIndicatorOnlyRef.current) mgr.applyAuxIndicatorOnlyLayout(true);
onManagerReady(mgr);
const unsub = mgr.subscribeCrosshair((p: MouseEventParams<Time>) => {
if (p.point?.y != null) {
const yPrice = mgr.yToPrice(p.point.y);
if (yPrice != null && yPrice > 0) lastCrosshairPriceRef.current = yPrice;
}
if (!p.time) { onCrosshair(null); return; }
const bar = mgr.getBarAtTime(p.time as number)
?? barsRef.current.find(b => b.time === p.time);
if (bar) {
const crosshairPrice = p.point?.y != null ? mgr.yToPrice(p.point.y) ?? undefined : undefined;
const indicatorValues = mgr.getIndicatorValuesFromParams(p);
onCrosshair({
time: p.time as number,
open: bar.open, high: bar.high, low: bar.low,
close: bar.close, volume: bar.volume,
crosshairPrice: crosshairPrice ?? undefined,
indicatorValues,
});
}
});
const container = containerRef.current!;
// ── 드로잉 클릭/더블클릭 감지 (capture phase → 인디케이터 클릭보다 우선) ──────
// cursor 모드 + trading + visible + !locked 일 때만 처리
const handleDrawingCapture = (e: MouseEvent) => {
if (suppressClickRef.current) {
suppressClickRef.current = false;
e.stopImmediatePropagation();
return;
}
if (
modeRef.current !== 'trading' ||
drawingToolRef.current !== 'cursor'||
!drawingsVisibleRef.current ||
drawingsLockedRef.current
) return;
const rect = container.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
const cssW = container.clientWidth;
const cssH = container.clientHeight;
const m = managerRef.current;
if (!m) return;
// 역순(나중에 그린 것 우선)으로 hit-test
const ds = drawingsRef.current;
for (let i = ds.length - 1; i >= 0; i--) {
if (hitTestDrawing(cx, cy, ds[i], m, cssW, cssH)) {
const hitId = ds[i].id;
// 인디케이터 컨텍스트 툴바가 열리지 않도록 이벤트 전파 차단
e.stopImmediatePropagation();
const now = Date.now();
const isDouble =
now - lastDrawingClickRef.current.time < 400 &&
lastDrawingClickRef.current.id === hitId;
lastDrawingClickRef.current = { id: hitId, time: now };
if (isDouble) {
if (drawingSingleTimerRef.current) {
clearTimeout(drawingSingleTimerRef.current);
drawingSingleTimerRef.current = null;
}
onDrawingDoubleClickRef.current?.(hitId, e.clientX, e.clientY);
} else {
if (drawingSingleTimerRef.current) clearTimeout(drawingSingleTimerRef.current);
drawingSingleTimerRef.current = setTimeout(() => {
drawingSingleTimerRef.current = null;
onDrawingClickRef.current?.(hitId, e.clientX, e.clientY);
}, 220);
}
return;
}
}
// 빈 공간 클릭 → 선택 해제
if (
modeRef.current === 'trading' &&
drawingToolRef.current === 'cursor'
) {
onDrawingClickRef.current?.(null, e.clientX, e.clientY);
}
};
// capture:true → subscribeSeriesClick(bubble) 보다 먼저 실행
container.addEventListener('click', handleDrawingCapture, { capture: true });
const onContextMenuCapture = (e: MouseEvent) => {
if (!onTradeOrderRequestRef.current || !marketRef.current) return;
const m = managerRef.current;
if (!m) return;
const rect = container.getBoundingClientRect();
const chartX = e.clientX - rect.left;
const chartY = e.clientY - rect.top;
const price = m.getTradePriceAtChartPoint(chartX, chartY);
if (price == null) return;
e.preventDefault();
e.stopPropagation();
setCtxMenu({ x: e.clientX, y: e.clientY, price });
};
container.addEventListener('contextmenu', onContextMenuCapture, { capture: true });
const wrapper = wrapperRef.current;
const isChartPlotPointer = (clientX: number, clientY: number) => {
const rect = container.getBoundingClientRect();
const chartX = clientX - rect.left;
const chartY = clientY - rect.top;
if (chartX < 0 || chartX > container.clientWidth) return null;
if (chartY < 0 || chartY > container.clientHeight) return null;
return { chartX, chartY };
};
const onPanPointerDown = (e: PointerEvent) => {
if (!canPanRef.current || e.button !== 0) return;
const target = e.target as HTMLElement | null;
if (target?.closest('[data-no-chart-pan], .chart-right-toolbar, .pane-legend-item, .pane-drag-handle, .pane-btn, .candle-pane-controls')) {
return;
}
const pt = isChartPlotPointer(e.clientX, e.clientY);
if (!pt) return;
if (mgr.isOnPriceAxis(pt.chartX, pt.chartY) || mgr.isOnTimeAxis(pt.chartY)) return;
const originPaneIndex = mgr.getPaneIndexAtChartY(pt.chartY);
const captureEl = wrapper ?? container;
try {
captureEl.setPointerCapture(e.pointerId);
} catch {
/* ignore */
}
panStateRef.current = {
active: true,
lastX: e.clientX,
lastY: e.clientY,
moved: false,
originPaneIndex,
};
container.style.cursor = 'grabbing';
};
const onPanPointerMove = (e: PointerEvent) => {
if (!panStateRef.current.active) return;
const dx = e.clientX - panStateRef.current.lastX;
const dy = e.clientY - panStateRef.current.lastY;
panStateRef.current.lastX = e.clientX;
panStateRef.current.lastY = e.clientY;
const allowVertical = panStateRef.current.originPaneIndex === 0;
if (!panStateRef.current.moved) {
const hitY = allowVertical && Math.abs(dy) > 2;
if (Math.abs(dx) > 2 || hitY) panStateRef.current.moved = true;
}
if (!panStateRef.current.moved) return;
if (e.cancelable) e.preventDefault();
managerRef.current?.panByPixelDelta(dx, dy, {
allowVerticalPan: allowVertical,
});
managerRef.current?.notifyViewportChanged();
};
const onPanPointerUp = (e: PointerEvent) => {
if (!panStateRef.current.active) return;
const captureEl = wrapper ?? container;
try {
if (captureEl.hasPointerCapture(e.pointerId)) {
captureEl.releasePointerCapture(e.pointerId);
}
} catch {
/* ignore */
}
if (panStateRef.current.moved) {
suppressClickRef.current = true;
managerRef.current?.notifyViewportChanged();
}
panStateRef.current.active = false;
container.style.cursor = canPanRef.current ? 'grab' : '';
};
const panCapture = true;
const panTarget = wrapper ?? container;
panTarget.addEventListener('pointerdown', onPanPointerDown, panCapture);
window.addEventListener('pointermove', onPanPointerMove, { passive: false });
window.addEventListener('pointerup', onPanPointerUp);
window.addEventListener('pointercancel', onPanPointerUp);
const onWheelCapture = (e: WheelEvent) => {
const m = managerRef.current;
if (!m) return;
const rect = container.getBoundingClientRect();
const chartX = e.clientX - rect.left;
const chartY = e.clientY - rect.top;
if (!m.isCandlePanePriceAxis(chartX, chartY)) return;
e.preventDefault();
e.stopPropagation();
m.zoomMainPriceByWheel(e.deltaY, chartY);
};
container.addEventListener('wheel', onWheelCapture, { capture: true, passive: false });
// subscribeSeriesClick 은 이제 native DOM 이벤트 기반.
// point 는 ChartManager 내부에서 chart-container 좌표로 계산된 screen 좌표.
/** 캔들 pane: 시리즈 위 → 설정 팝업, 빈 공간 → 전체보기/원복 토글 */
const onCandlePaneDblClick = (e: MouseEvent) => {
const m = managerRef.current;
if (!m) return;
const rect = container.getBoundingClientRect();
const chartX = e.clientX - rect.left;
const chartY = e.clientY - rect.top;
const entryId = m.resolveEntryAtChartPoint(chartX, chartY);
if (entryId && entryId !== '__main__') {
e.preventDefault();
e.stopImmediatePropagation();
suppressClickRef.current = true;
seriesDblSuppressRef.current = true;
onSeriesDoubleClickRef.current?.(entryId, { x: e.clientX, y: e.clientY });
return;
}
if (!m.isInCandlePlotArea(chartX, chartY)) return;
e.preventDefault();
e.stopImmediatePropagation();
suppressClickRef.current = true;
seriesDblSuppressRef.current = true;
if (m.isClickOnMainSeries(chartX, chartY)) {
onSeriesDoubleClickRef.current?.('__main__', { x: e.clientX, y: e.clientY });
} else if (!candleOnlyLocked) {
toggleCandleOnlyRef.current();
}
};
container.addEventListener('dblclick', onCandlePaneDblClick, { capture: true });
const unsubClick = mgr.subscribeSeriesClick(
(entryId, point) => { onSeriesClick?.(entryId, point); },
(entryId, point) => {
if (seriesDblSuppressRef.current) {
seriesDblSuppressRef.current = false;
return;
}
// 캔들 pane 더블클릭은 dblclick 캡처에서 처리 (시리즈/빈공간 구분)
if (entryId === '__main__') return;
onSeriesDoubleClick?.(entryId, point);
},
);
// autoSize:true 를 쓰면 LWC 가 내부 ResizeObserver 로 자동 크기 조절.
// 이 외부 ResizeObserver 는 applyPaneLayout 재트리거 + 데이터 재로드 보장용.
let prevW = 0, prevH = 0;
const ro = new ResizeObserver((entries) => {
let width = 0;
let height = 0;
for (const e of entries) {
width = Math.max(width, e.contentRect.width);
height = Math.max(height, e.contentRect.height);
}
if (width === prevW && height === prevH) return;
// 이전 크기가 0이었다가 유효해진 경우 (= display:none → visible 전환)
const wasHidden = prevW <= 0 || prevH <= 0;
if (width <= 0 || height <= 0) return;
// 캔들 전체보기: 컨테이너가 래퍼보다 커지면 즉시 클램프 (ResizeObserver ↔ autoSize 루프 차단)
if (candleOnlyModeRef.current) {
const wh = wrapperRef.current?.clientHeight ?? 0;
if (wh > 0 && height > wh + 4) {
prevW = width;
prevH = wh;
const m = managerRef.current;
if (m) applyPaneLayout(m);
return;
}
}
prevW = width; prevH = height;
if (resizeDebounceRef.current) clearTimeout(resizeDebounceRef.current);
resizeDebounceRef.current = window.setTimeout(() => {
resizeDebounceRef.current = null;
const m = managerRef.current;
if (!m || reloadInFlightRef.current) return;
// 컨테이너가 처음으로 유효한 크기를 가질 때:
// 데이터가 이미 barsRef에 있지만 차트에 아직 세팅되지 않은 경우 (예: 멀티레이아웃 초기 렌더) 재로드
if (barsRef.current.length > 0 && !m.hasMainSeries()) {
const bm = barsMarketRef.current;
if (!canApplyChartBars(barsRef.current, bm, marketRef.current)) return;
void reloadAll(
m,
barsRef.current,
prevChartType.current,
prevTheme.current,
prevLogScale.current,
indicatorsRef.current,
);
return;
}
const savedLR = wasHidden && m.hasMainSeries() ? m.getVisibleLogicalRange() : null;
applyPaneLayout(m);
// 숨김→표시 전환: 줌/스크롤 위치 유지 (setInitialVisibleRange 로 초기화하면 화면이 튐)
if (wasHidden && m.hasMainSeries()) {
requestAnimationFrame(() => {
if (savedLR) {
m.applyVisibleLogicalRange(savedLR.from, savedLR.to);
} else {
applyInitialVisibleRange(m);
}
});
}
}, 80);
});
const wrapperEl = wrapperRef.current;
if (wrapperEl) ro.observe(wrapperEl);
ro.observe(container);
const bootstrapIfReady = () => {
const m = managerRef.current;
if (!m || m.hasMainSeries()) return;
const data = barsRef.current;
if (!canApplyChartBars(data, barsMarketRef.current, marketRef.current)) return;
void reloadAllRef.current(
m,
data,
prevChartType.current,
prevTheme.current,
prevLogScale.current,
indicatorsRef.current,
);
};
requestAnimationFrame(() => requestAnimationFrame(bootstrapIfReady));
return () => {
unsub();
unsubClick();
container.removeEventListener('click', handleDrawingCapture, { capture: true });
container.removeEventListener('contextmenu', onContextMenuCapture, { capture: true });
container.removeEventListener('dblclick', onCandlePaneDblClick, { capture: true });
panTarget.removeEventListener('pointerdown', onPanPointerDown, panCapture);
container.removeEventListener('wheel', onWheelCapture, { capture: true });
window.removeEventListener('pointermove', onPanPointerMove);
window.removeEventListener('pointerup', onPanPointerUp);
window.removeEventListener('pointercancel', onPanPointerUp);
if (drawingSingleTimerRef.current) {
clearTimeout(drawingSingleTimerRef.current);
drawingSingleTimerRef.current = null;
}
reloadSafetyTimers.current.forEach(clearTimeout);
reloadSafetyTimers.current = [];
if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current);
if (resizeDebounceRef.current) clearTimeout(resizeDebounceRef.current);
ro.disconnect();
managerRef.current?.destroy();
managerRef.current = null;
setChartMgr(null);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
managerRef.current?.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat);
managerRef.current?.setChartTimeFormat(chartTimeFormat);
}, [displayTimezone, timeframe, chartTimeFormat]);
// 종목·타임프레임 변경 시 barsKey 캐시만 초기화 (reloadAll 은 bars effect 에서 1회만)
useEffect(() => {
if (prevMarket.current === market && prevMarketTf.current === timeframe) return;
prevMarket.current = market;
prevMarketTf.current = timeframe;
prevBarsKey.current = '';
prevIndKey.current = '';
prevIndicatorsListRef.current = [];
lastAppliedMarketRef.current = '';
barsRef.current = [];
reloadRetryRef.current = 0;
reloadGenRef.current += 1;
reloadInFlightRef.current = false;
reloadCoalesceRef.current = false;
reloadSafetyTimers.current.forEach(clearTimeout);
reloadSafetyTimers.current = [];
setChartPaintReady(false);
managerRef.current?.clearForSymbolChange();
managerRef.current?.cancelPendingIndicatorUpdates();
}, [market, timeframe]);
// ── 데이터/인디케이터 동기화 ─────────────────────────────────────────────
useEffect(() => {
const mgr = managerRef.current;
if (!mgr || !chartMgr || bars.length < 2) return;
if (!canApplyChartBars(bars, barsMarket, market)) return;
if (!chartVisible) {
// 설정 등 다른 화면에서 숨김 중 — 키만 동기화해 복귀 시 불필요한 전체 재로드 방지
prevBarsKey.current = barsKey(bars, market);
syncIndicatorTrackingRefs(indicators);
prevChartType.current = chartType;
prevTheme.current = theme;
prevLogScale.current = logScale;
return;
}
// manager 준비 전·종목 미적용 시 reloadAll 강제
if (!mgr.hasMainSeries() || lastAppliedMarketRef.current !== market) {
void reloadAll(mgr, bars, chartType, theme, logScale, indicators);
return;
}
const bk = barsKey(bars, market);
const ik = indKey(indicators);
const barsChanged = bk !== prevBarsKey.current;
const indChanged = ik !== prevIndKey.current;
const ctChanged = chartType !== prevChartType.current;
const thChanged = theme !== prevTheme.current;
const lsChanged = logScale !== prevLogScale.current;
const expectIndicators = countExpectedVisibleIndicators(indicators);
const loadedIndicators = mgr.getLoadedIndicatorCount();
const indicatorsIncomplete = expectIndicators > 0
&& mgr.hasMainSeries()
&& loadedIndicators < expectIndicators;
if (!barsChanged && !indChanged && !ctChanged && !thChanged && !lsChanged && !indicatorsIncomplete) return;
if (thChanged && !barsChanged && !indChanged && !ctChanged && !lsChanged && !indicatorsIncomplete) {
prevTheme.current = theme;
mgr.setTheme(theme);
return;
}
if (barsChanged || ctChanged) {
void reloadAll(mgr, bars, chartType, theme, logScale, indicators);
} else if (indicatorsIncomplete && !indChanged) {
// 지표 목록은 같지만 일부만 로드된 경우 — 누락분만 추가.
// 차트에 이전 탭 id가 남아 있으면(설정 화면에서 탭 교체 등) 전체 재로드.
const loadedIds = mgr.getLoadedIndicatorIds();
if (chartHasStaleIndicators(loadedIds, indicators)) {
reloadIndicatorsWithCover(mgr, indicators, () => {
syncIndicatorTrackingRefs(indicators);
prevBarsKey.current = barsKey(bars, market);
});
} else {
void repairMissingIndicators(mgr, indicators).then(() => {
syncIndicatorTrackingRefs(indicators);
prevBarsKey.current = barsKey(bars, market);
});
}
} else if (lsChanged) {
prevLogScale.current = logScale;
mgr.setLogScale(logScale);
} else if (indChanged) {
if (indicatorSyncInFlightRef.current) {
pendingIndicatorResyncRef.current = true;
return;
}
const prevList = prevIndicatorsListRef.current;
const prevPK = prevIndKey.current.split('@@')[0] ?? '';
const currPK = paramKey(indicators);
const prevSK = prevIndKey.current.split('@@')[1] ?? '';
const currSK = styleKey(indicators);
const needsRecalc = prevPK !== currPK;
const chartChange = classifyIndicatorChartChange(prevList, indicators);
const prevMap = new Map(prevList.map(i => [i.id, i]));
const removedIds = prevList
.filter(p => !indicators.some(c => c.id === p.id))
.map(p => p.id);
const addedConfigs = indicators.filter(c => !prevMap.has(c.id));
const paramsChangedConfigs = indicators.filter(c => {
const p = prevMap.get(c.id);
if (!p) return false;
return singleIndParamKey(p) !== singleIndParamKey(c);
});
if (
chartChange.mode === 'reorder-only'
|| chartChange.mode === 'layout-changed'
) {
/** 순서·병합·분리 — 설정에 맞게 sub-pane 전체 재배치 */
indicatorSyncInFlightRef.current = true;
reloadIndicatorsWithCover(mgr, indicators, () => {
syncIndicatorTrackingRefs(indicators);
indicatorSyncInFlightRef.current = false;
});
} else if (!needsRecalc && prevSK !== currSK) {
for (const ind of indicators) {
mgr.applyIndicatorStyle(ind);
}
void mgr.repairDualLineSeries();
syncIndicatorTrackingRefs(indicators);
} else if (
removedIds.length > 0
&& addedConfigs.length === 0
&& paramsChangedConfigs.length === 0
) {
const savedLR = mgr.getVisibleLogicalRange();
indicatorSyncInFlightRef.current = true;
mgr.removeIndicators(removedIds);
afterIndicatorPaneMutation(mgr);
mgr.notifyPaneLayoutChanged();
restoreLogicalRange(mgr, savedLR);
syncIndicatorTrackingRefs(indicators);
indicatorSyncInFlightRef.current = false;
requestAnimationFrame(() => requestAnimationFrame(() => {
afterIndicatorPaneMutation(mgr);
mgr.notifyPaneLayoutChanged();
}));
void repairMissingIndicators(mgr, indicators);
} else if (
addedConfigs.length > 0
&& removedIds.length > 0
&& paramsChangedConfigs.length === 0
) {
/** 지표 교체(제거+추가 동시) — CCI 제거 후 재추가 등 orphan pane·pane index 어긋남 방지 */
const savedLR = mgr.getVisibleLogicalRange();
indicatorSyncInFlightRef.current = true;
mgr.removeIndicators(removedIds);
void mgr.addIndicatorsBatch(addedConfigs).then(() => {
afterIndicatorPaneMutation(mgr);
mgr.notifyPaneLayoutChanged();
restoreLogicalRange(mgr, savedLR);
syncIndicatorTrackingRefs(indicators);
indicatorSyncInFlightRef.current = false;
requestAnimationFrame(() => requestAnimationFrame(() => {
afterIndicatorPaneMutation(mgr);
mgr.notifyPaneLayoutChanged();
[50, 150, 350].forEach(ms => {
setTimeout(() => mgr.notifyPaneLayoutChanged(), ms);
});
}));
});
} else if (
addedConfigs.length > 0
&& removedIds.length === 0
&& paramsChangedConfigs.length === 0
) {
const savedLR = mgr.getVisibleLogicalRange();
indicatorSyncInFlightRef.current = true;
/** 복사 등 목록 중간 삽입 — pane 순서를 맞추려면 전체 재로드 */
const insertIdx = addedConfigs.length === 1
? indicators.findIndex(i => i.id === addedConfigs[0].id)
: -1;
const reloadForPaneOrder = insertIdx >= 0 && insertIdx < indicators.length - 1;
const finishAdd = () => {
afterIndicatorPaneMutation(mgr);
mgr.notifyPaneLayoutChanged();
restoreLogicalRange(mgr, savedLR);
syncIndicatorTrackingRefs(indicators);
indicatorSyncInFlightRef.current = false;
requestAnimationFrame(() => requestAnimationFrame(() => {
afterIndicatorPaneMutation(mgr);
mgr.notifyPaneLayoutChanged();
[50, 150, 350].forEach(ms => {
setTimeout(() => mgr.notifyPaneLayoutChanged(), ms);
});
}));
};
if (reloadForPaneOrder) {
reloadIndicatorsWithCover(mgr, indicators, finishAdd);
} else {
void mgr.addIndicatorsBatch(addedConfigs).then(finishAdd);
}
} else if (
paramsChangedConfigs.length > 0
&& removedIds.length === 0
&& addedConfigs.length === 0
) {
const savedLR = mgr.getVisibleLogicalRange();
const syncGen = ++indicatorReloadGenRef.current;
indicatorSyncInFlightRef.current = true;
setPaneLegendPaused(true);
void mgr.refreshIndicatorParamsInPlace(paramsChangedConfigs).then(() => {
if (syncGen !== indicatorReloadGenRef.current) {
indicatorSyncInFlightRef.current = false;
setPaneLegendPaused(false);
return;
}
afterIndicatorPaneMutation(mgr);
mgr.notifyPaneLayoutChanged();
restoreLogicalRange(mgr, savedLR);
syncIndicatorTrackingRefs(indicators);
indicatorSyncInFlightRef.current = false;
setPaneLegendPaused(false);
flushPendingIndicatorResync(mgr, syncGen);
});
} else if (chartChange.mode === 'full') {
reloadIndicatorsWithCover(mgr, indicators, () => {
syncIndicatorTrackingRefs(indicators);
});
} else {
syncIndicatorTrackingRefs(indicators);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, chartVisible, reloadIndicatorsWithCover, afterIndicatorPaneMutation, restoreLogicalRange, syncIndicatorTrackingRefs, repairMissingIndicators, flushPendingIndicatorResync]);
// 원격·모바일 등 레이아웃·데이터 로드 지연 후 pane·가격축 안정화
const layoutRecoveryAttemptedRef = useRef(false);
useEffect(() => {
layoutRecoveryAttemptedRef.current = false;
lastViewportFitRef.current = null;
}, [market, timeframe]);
useEffect(() => {
if (!chartVisible || skipViewportRecovery) return;
const mgr = managerRef.current;
if (!mgr || !chartMgr || bars.length < 2) return;
if (!canApplyChartBars(bars, barsMarket, market)) return;
if (!mgr.hasMainSeries()) return;
const timers = [600, 1200, 2500, 4000].map(delay =>
window.setTimeout(() => {
if (reloadInFlightRef.current) return;
const m = managerRef.current;
if (!m?.hasMainSeries()) return;
const w = wrapperRef.current;
if (!w || w.clientHeight < MIN_VALID_LAYOUT_HEIGHT) return;
normalizeChartViewport(m);
if (delay >= 2500 && m.isMainPriceScaleLikelyCorrupt() && !layoutRecoveryAttemptedRef.current) {
layoutRecoveryAttemptedRef.current = true;
prevBarsKey.current = '';
void reloadAllRef.current(
m,
barsRef.current,
prevChartType.current,
prevTheme.current,
prevLogScale.current,
indicatorsRef.current,
);
}
}, delay),
);
return () => { timers.forEach(clearTimeout); };
}, [chartMgr, bars.length, barsMarket, market, chartVisible, normalizeChartViewport, skipViewportRecovery]);
// 데이터는 준비됐으나 메인 시리즈가 없는 빈 차트 자동 복구 (종목 전환·레이아웃 전환 직후)
useEffect(() => {
if (!chartVisible) return;
const mgr = managerRef.current;
if (!mgr || !chartMgr || bars.length < 2) return;
if (!canApplyChartBars(bars, barsMarket, market)) return;
if (mgr.hasMainSeries()) return;
const timers = [0, 80, 200, 500, 1200, 2500].map(delay =>
window.setTimeout(() => {
const m = managerRef.current;
if (!m || m.hasMainSeries()) return;
const data = barsRef.current;
if (!canApplyChartBars(data, barsMarketRef.current, marketRef.current)) return;
void reloadAllRef.current(
m,
data,
prevChartType.current,
prevTheme.current,
prevLogScale.current,
indicatorsRef.current,
);
}, delay),
);
return () => { timers.forEach(clearTimeout); };
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, chartVisible]);
// 종목 전환 직후 reloadAll 이 중단되어 캔들만 남은 경우 자동 복구
useEffect(() => {
if (!chartVisible) return;
const mgr = managerRef.current;
if (!mgr || !chartMgr || bars.length < 2) return;
if (!canApplyChartBars(bars, barsMarket, market)) return;
const expected = countExpectedVisibleIndicators(indicators);
if (expected === 0) return;
const loadedIds = mgr.getLoadedIndicatorIds();
if (
loadedIds.size >= expected
&& !chartHasStaleIndicators(loadedIds, indicators)
) return;
const timers = [120, 400, 900].map(delay =>
window.setTimeout(() => {
if (reloadInFlightRef.current || indicatorSyncInFlightRef.current) return;
const m = managerRef.current;
if (!m || !m.hasMainSeries()) return;
if (!canApplyChartBars(bars, barsMarket, market)) return;
const inds = indicatorsRef.current;
const exp = countExpectedVisibleIndicators(inds);
if (exp === 0) return;
const ids = m.getLoadedIndicatorIds();
if (chartHasStaleIndicators(ids, inds)) {
reloadIndicatorsWithCover(m, inds, () => {
syncIndicatorTrackingRefs(inds);
prevBarsKey.current = barsKey(bars, market);
});
return;
}
if (ids.size >= exp) return;
void repairMissingIndicators(m, inds).then(() => {
if (
m.getLoadedIndicatorCount() >= exp
&& !chartHasStaleIndicators(m.getLoadedIndicatorIds(), inds)
) return;
prevBarsKey.current = '';
queueIndicatorRecovery();
});
}, delay),
);
return () => { timers.forEach(clearTimeout); };
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, chartVisible, queueIndicatorRecovery, repairMissingIndicators, reloadIndicatorsWithCover, syncIndicatorTrackingRefs]);
// cursor 모드에서 드로잉 위에 있으면 pointer 커서로 변경
const handleWrapperMouseMove = useCallback((e: React.PointerEvent) => {
if (panStateRef.current.active) return;
if (canPanRef.current) {
const container = containerRef.current;
if (!container) return;
if (
modeRef.current === 'trading' &&
drawingToolRef.current === 'cursor' &&
drawingsVisibleRef.current &&
!drawingsLockedRef.current
) {
const rect = container.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
const m = managerRef.current;
if (m) {
const isHit = drawingsRef.current.some(d =>
hitTestDrawing(cx, cy, d, m, container.clientWidth, container.clientHeight),
);
container.style.cursor = isHit ? 'pointer' : 'grab';
return;
}
}
container.style.cursor = 'grab';
return;
}
if (
modeRef.current !== 'trading' ||
drawingToolRef.current !== 'cursor' ||
!drawingsVisibleRef.current ||
drawingsLockedRef.current
) {
if (containerRef.current) containerRef.current.style.cursor = '';
return;
}
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
const cssW = container.clientWidth;
const cssH = container.clientHeight;
const m = managerRef.current;
if (!m) return;
const ds = drawingsRef.current;
const isHit = ds.some(d => hitTestDrawing(cx, cy, d, m, cssW, cssH));
container.style.cursor = isHit ? 'pointer' : '';
}, []);
const fmtCtxPrice = (p: number) => formatUpbitKrwPrice(p);
const closeCtxAndRequest = useCallback((side: 'buy' | 'sell') => {
if (!ctxMenu || !market) return;
onTradeOrderRequestRef.current?.({ market, price: ctxMenu.price, side });
setCtxMenu(null);
}, [ctxMenu, market]);
const showChartLoading = !chartPaintReady || dataLoading;
const showRightToolbar = !!(
chartMgr
&& !candleOnlyMode
&& chartPaintReady
&& (effectiveShowChartRightToolbar || showOverlayControlsOnly)
);
return (
// wrapperRef: 스크롤 영역 (overflow-y: auto → App.css .tv-chart-wrap)
<div
ref={wrapperRef}
className={`tv-chart-wrap${candleOnlyMode ? ' tv-chart-wrap--candle-only' : ''}${chartPaintReady ? '' : ' tv-chart-wrap--bootstrapping'}`}
style={{ flex: 1, position: 'relative', minHeight: 0 }}
onPointerMove={handleWrapperMouseMove}
>
{showChartLoading && (
<div className="chart-loading chart-loading--chart-area" role="status" aria-live="polite">
<div className="loading-spinner" />
<span>차트 로딩중...</span>
</div>
)}
<div className="chart-main-row">
<div
ref={containerRef}
className="chart-container"
/>
{/* 캔들 pane 하단 드래그 핸들 — 캔들/보조지표 높이 비율 조절 */}
{candlePaneBottomY != null && !candleOnlyMode && (!paneLayoutClamp || showPaneResizeHandle) && chartPaintReady && (
<div
className="chart-pane-resize-handle"
style={{ top: candlePaneBottomY }}
onPointerDown={onPaneResizePointerDown}
onPointerMove={onPaneResizePointerMove}
onPointerUp={onPaneResizePointerUp}
onPointerCancel={onPaneResizePointerUp}
/>
)}
{showRightToolbar && (
<ChartRightToolbar
manager={chartMgr}
indicators={indicators}
chartHeight={chartBodyHeight}
paused={paneLegendPaused}
candleOverlayToggles={candleOverlayToggles}
onToggleCandleOverlay={onToggleCandleOverlay}
overlayControlsOnly={showOverlayControlsOnly}
customOverlayActive={customOverlayActive}
onCustomOverlayActiveChange={onCustomOverlayActiveChange}
onOpenCustomOverlaySettings={onOpenCustomOverlaySettings}
custom1OverlayActive={custom1OverlayActive}
onCustom1OverlayActiveChange={onCustom1OverlayActiveChange}
onOpenCustom1OverlaySettings={onOpenCustom1OverlaySettings}
priceExtremeLabelsVisible={priceExtremeLabelsVisible}
onTogglePriceExtremeLabels={onTogglePriceExtremeLabels}
onSplit={onSplitIndicatorPane}
onRemove={onRemoveIndicator}
onDuplicate={onDuplicateIndicator}
onExpand={onExpandIndicator}
onRestore={onRestoreIndicators}
focusedId={focusedIndicatorId}
onDragStart={(id, e) => paneDragRef.current?.(id, e)}
onToggleHidden={onToggleIndicatorHidden}
onSettings={onOpenIndicatorSettings}
/>
)}
</div>
{ctxMenu && (
<ChartContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
marketLabel={getKoreanName(market) || market.replace(/^KRW-/, '')}
priceLabel={`${fmtCtxPrice(ctxMenu.price)} KRW`}
onBuy={() => closeCtxAndRequest('buy')}
onSell={() => closeCtxAndRequest('sell')}
onClose={() => setCtxMenu(null)}
/>
)}
{chartMgr && (
<CandlePaneTimeAxisPortal
manager={chartMgr}
getContainer={() => containerRef.current}
/>
)}
{chartMgr && chartPaintReady && paneSeparatorOptions && (
<PaneSeparatorOverlay
manager={chartMgr}
options={paneSeparatorOptions}
/>
)}
{chartMgr && (
<DrawingCanvas
manager={chartMgr}
activeTool={drawingTool}
drawings={drawings}
onAddDrawing={onAddDrawing}
onZoom={(x0, x1) => chartMgr.zoomToXRange(x0, x1)}
theme={theme}
enabled={mode === 'trading' || drawingTool === 'zoom' || drawingTool === 'magnifier'}
visible={drawingsVisible}
locked={drawingsLocked}
selectedDrawingId={selectedDrawingId}
/>
)}
{chartMgr && chartPaintReady && (
<TradeSignalLabelOverlay
manager={chartMgr}
getMountTarget={() => wrapperRef.current}
/>
)}
{/* PaneLegend: containerRef 를 상태에 저장해 React 렌더와 동기화 */}
{chartMgr && showCandlePaneControls && !candleOnlyLocked && (
<CandlePaneControlsPortal
manager={chartMgr}
getContainer={() => containerRef.current}
candleOnly={candleOnlyMode}
onExpand={() => setCandleOnlyMode(true)}
onRestore={() => setCandleOnlyMode(false)}
/>
)}
{chartMgr && focusedIndicatorId && onRestoreIndicators && chartPaintReady && (
<IndicatorPaneControlsPortal
manager={chartMgr}
indicators={indicators}
getContainer={() => containerRef.current}
focusedId={focusedIndicatorId}
onRestore={onRestoreIndicators}
/>
)}
{chartMgr && !candleOnlyMode && showPaneLegend && chartPaintReady && (
<PaneLegendPortal
key={indKey(indicators)}
manager={chartMgr}
indicators={indicators}
paused={paneLegendPaused}
getContainer={() => containerRef.current}
onHoverName={(id, sx, sy) => onHoverPaneLegend?.(id, sx, sy)}
onLeaveName={() => onLeavePaneLegend?.()}
onDoubleClick={(id) => onSeriesDoubleClick?.(id, { x: 0, y: 0 })}
onReorder={onReorderIndicators}
onMerge={onMergeIndicators}
dragHandleRef={paneDragRef}
crosshairInfoVisible={crosshairInfoVisible}
/>
)}
{/* 마우스 오버 시 표시되는 플로팅 줌/스크롤 툴바 */}
{chartMgr && showHoverToolbar && (
<ChartHoverToolbar
onZoomOut={() => managerRef.current?.zoomOut()}
onZoomIn={() => managerRef.current?.zoomIn()}
onFit={() => managerRef.current?.fitContent()}
onScrollLeft={() => managerRef.current?.scrollLeft()}
onScrollRight={() => managerRef.current?.scrollRight()}
onFullView={onFullView}
/>
)}
{/* 돋보기 오버레이 */}
<ChartMagnifier
containerRef={containerRef}
wrapperRef={wrapperRef}
enabled={magnifierEnabled}
onClose={onMagnifierClose ?? (() => {})}
/>
</div>
);
};
const CandlePaneTimeAxisPortal: React.FC<{
manager: ChartManager;
getContainer: () => HTMLElement | null;
}> = ({ manager, getContainer }) => {
const [el, setEl] = useState<HTMLElement | null>(null);
useEffect(() => {
const c = getContainer();
if (c) { setEl(c); return; }
const tid = setTimeout(() => setEl(getContainer()), 100);
return () => clearTimeout(tid);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!el) return null;
return <CandlePaneTimeAxis manager={manager} containerEl={el} />;
};
const IndicatorPaneControlsPortal: React.FC<{
manager: ChartManager;
indicators: IndicatorConfig[];
getContainer: () => HTMLElement | null;
focusedId: string;
onRestore: () => void;
}> = ({ manager, indicators, getContainer, focusedId, onRestore }) => {
const [el, setEl] = useState<HTMLElement | null>(null);
useEffect(() => {
const c = getContainer();
if (c) { setEl(c); return; }
const tid = setTimeout(() => setEl(getContainer()), 100);
return () => clearTimeout(tid);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!el) return null;
return (
<IndicatorPaneControls
manager={manager}
containerEl={el}
indicators={indicators}
focusedId={focusedId}
onRestore={onRestore}
/>
);
};
const CandlePaneControlsPortal: React.FC<{
manager: ChartManager;
getContainer: () => HTMLElement | null;
candleOnly: boolean;
onExpand: () => void;
onRestore: () => void;
}> = ({ manager, getContainer, candleOnly, onExpand, onRestore }) => {
const [el, setEl] = useState<HTMLElement | null>(null);
useEffect(() => {
const c = getContainer();
if (c) { setEl(c); return; }
const tid = setTimeout(() => setEl(getContainer()), 100);
return () => clearTimeout(tid);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!el) return null;
return (
<CandlePaneControls
manager={manager}
containerEl={el}
candleOnly={candleOnly}
onExpand={onExpand}
onRestore={onRestore}
/>
);
};
// ── containerEl 을 안전하게 주입하는 래퍼 ───────────────────────────────────
const PaneLegendPortal: React.FC<
Omit<PaneLegendProps, 'containerEl'> & { getContainer: () => HTMLElement | null }
> = ({ getContainer, paused, ...rest }) => {
const [el, setEl] = useState<HTMLElement | null>(null);
useEffect(() => {
// 마운트 직후 containerEl 확보
const container = getContainer();
if (container) { setEl(container); return; }
// 혹시 null 이면 짧게 재시도
const tid = setTimeout(() => setEl(getContainer()), 100);
return () => clearTimeout(tid);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!el) return null;
return <PaneLegend {...rest} containerEl={el} paused={paused} />;
};
// ── 변경 감지용 키 생성 헬퍼 ────────────────────────────────────────────────
function countExpectedVisibleIndicators(inds: IndicatorConfig[]): number {
return inds.filter(i => i.hidden !== true).length;
}
function barsKey(bars: OHLCVBar[], market = ''): string {
if (bars.length === 0) return '';
const last = bars[bars.length - 1];
// last.close 제외 — 틱마다 full reload 방지 (실시간은 updateBar/appendBar 경로)
// volume 합계 — STOMP volume=0 → Upbit 폴백 후 OBV 등 지표 재계산 유도
const volSum = bars.reduce((s, b) => s + (Number(b.volume) || 0), 0);
const volFlag = volSum > 0 ? 'v' : '0';
return `${market}:${bars.length}:${bars[0].time}:${last.time}:${volFlag}`;
}
/**
* 재계산이 필요한 변경 키 (params·플롯 구성·추가·제거) — 배열 순서 기준
* 이 키가 바뀌면 시리즈를 재생성해야 한다.
*/
function paramKey(inds: IndicatorConfig[]): string {
return inds.map(i =>
`${i.id}|${JSON.stringify(i.params)}|${(i.plots ?? []).map(p => p.id).sort().join(',')}|${i.mergedWith ?? ''}`
).join(';');
}
/**
* 순서 무관 파라미터 키 — id 기준으로 정렬
* paramKey 값은 같지만 배열 순서만 다른 경우를 감지하는 데 사용.
*/
function sortedParamKey(inds: IndicatorConfig[]): string {
return [...inds]
.sort((a, b) => a.id.localeCompare(b.id))
.map(i => `${i.id}|${JSON.stringify(i.params)}|${(i.plots ?? []).map(p => p.id).sort().join(',')}|${i.mergedWith ?? ''}`)
.join(';');
}
/**
* 스타일만 포함하는 키 (색상·선폭·plotVisibility)
* 이 키만 바뀌면 시리즈를 재생성하지 않고 in-place 업데이트.
*/
function styleKey(inds: IndicatorConfig[]): string {
return inds.map(i =>
`${i.id}|${i.hidden ? '1' : '0'}|${i.lastValueVisible === false ? '0' : '1'}|${(i.plots ?? []).map(p => `${p.color ?? ''}:${p.lineWidth ?? 1}:${p.lineStyle ?? 'solid'}:${p.histogramUpColor ?? ''}:${p.histogramDownColor ?? ''}`).join(',')}|${JSON.stringify(i.plotVisibility ?? {})}|${JSON.stringify((i.hlines ?? []).map(h => `${h.price}:${h.color}:${h.visible ?? true}:${h.lineStyle ?? 'dashed'}:${h.lineWidth ?? 1}`))}|${JSON.stringify(i.cloudColors ?? {})}|${JSON.stringify(i.hlinesBackground ?? {})}|${JSON.stringify(i.bandBackground ?? {})}`
).join(';');
}
/** 전체 변경 감지 키 (두 키를 합침) */
function indKey(inds: IndicatorConfig[]): string {
return paramKey(inds) + '@@' + styleKey(inds);
}
export default memo(TradingChart);