Files
goldenChart/frontend/src/components/TradingChart.tsx
T
2026-05-30 01:28:58 +09:00

1655 lines
64 KiB
TypeScript

import React, { useRef, useEffect, useLayoutEffect, useState, useCallback } 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 { 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));
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 from './ChartRightToolbar';
import CandlePaneTimeAxis from './CandlePaneTimeAxis';
import ChartHoverToolbar from './ChartHoverToolbar';
import ChartMagnifier from './ChartMagnifier';
import ChartContextMenu from './ChartContextMenu';
import CandlePaneControls from './CandlePaneControls';
import { getKoreanName } from '../utils/marketNameCache';
import { DEFAULT_DISPLAY_TIMEZONE } from '../utils/timezone';
import { chartHasStaleIndicators, classifyIndicatorChartChange, singleIndParamKey } from '../utils/indicatorChartSync';
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
import PaneSeparatorOverlay from './PaneSeparatorOverlay';
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) */
seriesPriceLabelsEnabled?: boolean;
/** 실시간 차트 화면 표시 여부 — false 이면 숨김 중 무거운 재로드 지연 */
chartVisible?: boolean;
/** pane(캔들·거래량·보조지표) 구분선 */
paneSeparatorOptions?: ChartPaneSeparatorOptions;
/** true: pane 합산 높이로 chart-container 를 키우지 않고 래퍼 높이에 맞춤 (미니 차트·알림 목록) */
paneLayoutClamp?: boolean;
/** 보조지표 pane 레전드(이름·값 오버레이) 표시 (기본 true, 알림 미니차트는 false) */
showPaneLegend?: boolean;
/** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 (기본 true) */
crosshairInfoVisible?: boolean;
}
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,
seriesPriceLabelsEnabled = true,
chartVisible = true,
paneSeparatorOptions,
paneLayoutClamp = false,
showPaneLegend = true,
crosshairInfoVisible = true,
}) => {
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 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);
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 chartTimeFormat = useChartTimeFormat();
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
/** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */
const [candleOnlyMode, setCandleOnlyMode] = useState(false);
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(() => {
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]);
const toggleCandleOnly = useCallback(() => {
setCandleOnlyMode(v => !v);
}, []);
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' : '';
el.style.touchAction = canPanRef.current ? 'none' : '';
}
}, [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]);
useEffect(() => {
managerRef.current?.setSeriesPriceLabelsEnabled(seriesPriceLabelsEnabled);
}, [seriesPriceLabelsEnabled]);
useEffect(() => {
if (!paneSeparatorOptions) return;
managerRef.current?.setPaneSeparatorOptions(paneSeparatorOptions);
}, [paneSeparatorOptions]);
/**
* pane 높이 재배분 + 스크롤 컨테이너 확장
*
* wrapper.clientHeight 를 정확한 가용 높이로 ChartManager 에 전달합니다.
* CSS Grid 레이아웃 초기화 지연으로 wrapperH=0 이 될 수 있으므로,
* retryCount 를 통해 최대 6회 자동 재시도합니다 (100→200→300ms…)
*/
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 <= 0) {
// CSS Grid 높이가 아직 계산되지 않은 경우: 지연 후 재시도 (최대 6회)
if (retryCount < 6) {
setTimeout(() => applyPaneLayout(mgr, retryCount + 1), 100 * (retryCount + 1));
}
return;
}
let required: number;
if (candleOnlyModeRef.current) {
mgr.applyCandleOnlyLayout(true, wrapperH);
required = wrapperH;
} else {
if (mgr.isCandleOnlyLayout()) {
mgr.restoreFromCandleFullscreen(wrapperH);
}
required = mgr.resetPaneHeights(wrapperH);
}
if (paneLayoutClamp) {
try {
const w = wrapper.clientWidth;
if (w > 0 && wrapperH > 0) mgr.resize(w, wrapperH);
} catch { /* ok */ }
}
if (!paneLayoutClamp && required > wrapperH + 4) {
container.style.height = `${required}px`;
} else {
container.style.height = '';
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paneLayoutClamp]);
/** 캔들 확대 보기 → 원복 직후 가격·시간축·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);
const futureBars = m.hasIchimoku() ? 28 : 0;
m.setInitialVisibleRange(DISPLAY_COUNT, futureBars);
};
requestAnimationFrame(() => requestAnimationFrame(runRestore));
[200, 500, 1000].forEach(delay => setTimeout(runRestore, delay));
}
}, [candleOnlyMode, chartMgr, applyPaneLayout]);
// ── 전체 재로드 (데이터 + 인디케이터) ─────────────────────────────────────
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 (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(() => {
const futureBars = mgr.hasIchimoku() ? 28 : 0;
mgr.setInitialVisibleRange(DISPLAY_COUNT, futureBars);
});
});
}, [applyPaneLayout]);
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;
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);
commitCandleLayout(mgr);
onCandlesReady?.();
lastAppliedMarketRef.current = market;
let stale = false;
for (const ind of inds) {
if (gen !== reloadGenRef.current) {
prevBarsKey.current = '';
stale = true;
break;
}
await mgr.addIndicator(ind);
}
if (stale) break;
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) {
commitCandleLayout(mgr);
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(() => {
applyPaneLayout(mgr);
requestAnimationFrame(() => {
const futureBars = mgr.hasIchimoku() ? 28 : 0;
mgr.setInitialVisibleRange(DISPLAY_COUNT, futureBars);
resolve();
});
})));
if (gen !== reloadGenRef.current) {
prevBarsKey.current = '';
break;
}
onDataLoaded?.();
reloadSafetyTimers.current = [300, 700, 1400].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;
}
const w = wrapperRef.current;
if (!w || w.clientHeight <= 0) return;
m.resetPaneHeights(w.clientHeight);
const fb = m.hasIchimoku() ? 28 : 0;
m.setInitialVisibleRange(DISPLAY_COUNT, fb);
if (delay === 1400) onDataLoaded?.();
}, delay)
);
} while (reloadCoalesceRef.current);
} finally {
reloadInFlightRef.current = false;
if (reloadCoalesceRef.current) {
reloadCoalesceRef.current = false;
queueFullReload();
}
}
}, [applyPaneLayout, commitCandleLayout, 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,
});
managerRef.current = mgr;
setChartMgr(mgr);
if (!volumeVisibleRef.current) {
const wrapperH = wrapperRef.current?.clientHeight ?? 0;
mgr.setVolumeVisible(false, wrapperH > 0 ? wrapperH : undefined);
}
mgr.setSeriesPriceLabelsEnabled(seriesPriceLabelsEnabled);
if (paneSeparatorOptions) mgr.setPaneSeparatorOptions(paneSeparatorOptions);
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 onPanPointerDown = (e: PointerEvent) => {
if (!canPanRef.current || e.button !== 0) return;
const rect = container.getBoundingClientRect();
const chartX = e.clientX - rect.left;
const chartY = e.clientY - rect.top;
if (mgr.isOnPriceAxis(chartX, chartY) || mgr.isOnTimeAxis(chartY)) return;
const originPaneIndex = mgr.getPaneIndexAtChartY(chartY);
try {
container.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,
});
};
const onPanPointerUp = () => {
if (!panStateRef.current.active) return;
if (panStateRef.current.moved) {
suppressClickRef.current = true;
}
managerRef.current?.endCandleAxisPan();
panStateRef.current.active = false;
container.style.cursor = canPanRef.current ? 'grab' : '';
};
container.addEventListener('pointerdown', onPanPointerDown);
window.addEventListener('pointermove', onPanPointerMove);
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 {
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(([entry]) => {
const { width, height } = entry.contentRect;
if (width === prevW && height === prevH) return;
// 이전 크기가 0이었다가 유효해진 경우 (= display:none → visible 전환)
const wasHidden = prevW <= 0 || prevH <= 0;
prevW = width; prevH = height;
if (width <= 0 || height <= 0) return;
if (resizeDebounceRef.current) clearTimeout(resizeDebounceRef.current);
resizeDebounceRef.current = window.setTimeout(() => {
resizeDebounceRef.current = null;
const m = managerRef.current;
if (!m) 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 {
const fb = m.hasIchimoku() ? 28 : 0;
m.setInitialVisibleRange(DISPLAY_COUNT, fb);
}
});
}
}, 80);
});
ro.observe(containerRef.current);
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 });
container.removeEventListener('pointerdown', onPanPointerDown);
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 = [];
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 isReorderOnly = needsRecalc
&& sortedParamKey(indicators) === sortedParamKey(prevList)
&& currSK === prevSK;
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 (!needsRecalc && prevSK !== currSK) {
for (const ind of indicators) {
mgr.applyIndicatorStyle(ind);
}
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;
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 (
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 (isReorderOnly) {
reloadIndicatorsWithCover(mgr, indicators, () => {
syncIndicatorTrackingRefs(indicators);
});
} else {
const change = classifyIndicatorChartChange(prevList, indicators);
if (change.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]);
// 데이터는 준비됐으나 메인 시리즈가 없는 빈 차트 자동 복구 (종목 전환·레이아웃 전환 직후)
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].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) =>
p >= 1000 ? Math.round(p).toLocaleString('ko-KR') : p.toFixed(p >= 1 ? 2 : 6);
const closeCtxAndRequest = useCallback((side: 'buy' | 'sell') => {
if (!ctxMenu || !market) return;
onTradeOrderRequestRef.current?.({ market, price: ctxMenu.price, side });
setCtxMenu(null);
}, [ctxMenu, market]);
return (
// wrapperRef: 스크롤 영역 (overflow-y: auto → App.css .tv-chart-wrap)
<div
ref={wrapperRef}
className="tv-chart-wrap"
style={{ flex: 1, position: 'relative', minHeight: 0 }}
onPointerMove={handleWrapperMouseMove}
>
<div className="chart-main-row">
<div
ref={containerRef}
className="chart-container"
/>
{chartMgr && !candleOnlyMode && showPaneLegend && (
<ChartRightToolbar
manager={chartMgr}
indicators={indicators}
chartHeight={chartBodyHeight}
paused={paneLegendPaused}
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 && 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}
/>
)}
{/* PaneLegend: containerRef 를 상태에 저장해 React 렌더와 동기화 */}
{chartMgr && (
<CandlePaneControlsPortal
manager={chartMgr}
getContainer={() => containerRef.current}
candleOnly={candleOnlyMode}
onExpand={() => setCandleOnlyMode(true)}
onRestore={() => setCandleOnlyMode(false)}
/>
)}
{chartMgr && !candleOnlyMode && showPaneLegend && (
<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 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 경로)
return `${market}:${bars.length}:${bars[0].time}:${last.time}`;
}
/**
* 재계산이 필요한 변경 키 (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 TradingChart;