1556 lines
60 KiB
TypeScript
1556 lines
60 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 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;
|
|
/** 우클릭 메뉴에서 매수·매도 선택 시 */
|
|
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;
|
|
}
|
|
|
|
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,
|
|
onTradeOrderRequest,
|
|
displayTimezone = DEFAULT_DISPLAY_TIMEZONE,
|
|
volumeVisible = true,
|
|
showHoverToolbar = true,
|
|
seriesPriceLabelsEnabled = true,
|
|
chartVisible = true,
|
|
paneSeparatorOptions,
|
|
paneLayoutClamp = false,
|
|
showPaneLegend = 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 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);
|
|
|
|
useEffect(() => {
|
|
if (barsMarket === market) {
|
|
barsRef.current = bars;
|
|
} else {
|
|
barsRef.current = [];
|
|
}
|
|
}, [bars, barsMarket, market]);
|
|
|
|
useEffect(() => {
|
|
indicatorsRef.current = indicators;
|
|
}, [indicators]);
|
|
|
|
useEffect(() => {
|
|
chartVisibleRef.current = chartVisible;
|
|
}, [chartVisible]);
|
|
|
|
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 && 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);
|
|
} 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);
|
|
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;
|
|
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);
|
|
}, [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);
|
|
restoreLogicalRange(mgr, savedLR);
|
|
syncIndicatorTrackingRefs(indicators);
|
|
indicatorSyncInFlightRef.current = false;
|
|
void repairMissingIndicators(mgr, indicators);
|
|
} 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);
|
|
restoreLogicalRange(mgr, savedLR);
|
|
syncIndicatorTrackingRefs(indicators);
|
|
indicatorSyncInFlightRef.current = false;
|
|
});
|
|
} else if (
|
|
paramsChangedConfigs.length > 0
|
|
&& removedIds.length === 0
|
|
&& addedConfigs.length === 0
|
|
) {
|
|
const savedLR = mgr.getVisibleLogicalRange();
|
|
const syncGen = ++indicatorReloadGenRef.current;
|
|
indicatorSyncInFlightRef.current = true;
|
|
void mgr.refreshIndicators(paramsChangedConfigs).then(() => {
|
|
if (syncGen !== indicatorReloadGenRef.current) {
|
|
indicatorSyncInFlightRef.current = false;
|
|
return;
|
|
}
|
|
afterIndicatorPaneMutation(mgr);
|
|
restoreLogicalRange(mgr, savedLR);
|
|
syncIndicatorTrackingRefs(indicators);
|
|
indicatorSyncInFlightRef.current = 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
|
|
ref={containerRef}
|
|
className="chart-container"
|
|
/>
|
|
{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
|
|
manager={chartMgr}
|
|
indicators={indicators}
|
|
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}
|
|
onSplit={onSplitIndicatorPane}
|
|
onExpand={onExpandIndicator}
|
|
onRemove={onRemoveIndicator}
|
|
onDuplicate={onDuplicateIndicator}
|
|
focusedId={focusedIndicatorId}
|
|
onRestore={onRestoreIndicators}
|
|
/>
|
|
)}
|
|
|
|
{/* 마우스 오버 시 표시되는 플로팅 줌/스크롤 툴바 */}
|
|
{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, ...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} />;
|
|
};
|
|
|
|
// ── 변경 감지용 키 생성 헬퍼 ────────────────────────────────────────────────
|
|
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;
|