2106 lines
83 KiB
TypeScript
2106 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 && chartPaintReady && (
|
|
<TradeSignalLabelOverlay manager={chartMgr} />
|
|
)}
|
|
{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}
|
|
/>
|
|
)}
|
|
{/* 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);
|