goldenChat base source add
This commit is contained in:
@@ -0,0 +1,951 @@
|
||||
import React, { useRef, useEffect, 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 { setIndicatorChartContext } from '../utils/indicatorRegistry';
|
||||
import { DISPLAY_COUNT } from '../hooks/useUpbitData';
|
||||
import DrawingCanvas, { hitTestDrawing } from './DrawingCanvas';
|
||||
import PaneLegend, { type PaneLegendProps } from './PaneLegend';
|
||||
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';
|
||||
|
||||
interface TradingChartProps {
|
||||
bars: OHLCVBar[];
|
||||
/** 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;
|
||||
/** 보조지표 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;
|
||||
}
|
||||
|
||||
const TradingChart: React.FC<TradingChartProps> = ({
|
||||
bars, 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,
|
||||
onReorderIndicators,
|
||||
onMergeIndicators,
|
||||
onSplitIndicatorPane,
|
||||
onExpandIndicator,
|
||||
onRemoveIndicator,
|
||||
onDuplicateIndicator,
|
||||
focusedIndicatorId,
|
||||
onRestoreIndicators,
|
||||
onTradeOrderRequest,
|
||||
displayTimezone = DEFAULT_DISPLAY_TIMEZONE,
|
||||
}) => {
|
||||
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 [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 prevChartType = useRef<ChartType>(chartType);
|
||||
const prevTheme = useRef<Theme>(theme);
|
||||
const prevLogScale = useRef<boolean>(logScale);
|
||||
|
||||
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
|
||||
/** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */
|
||||
const [candleOnlyMode, setCandleOnlyMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
barsRef.current = bars;
|
||||
}, [bars]);
|
||||
|
||||
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]);
|
||||
|
||||
/**
|
||||
* 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 (required > wrapperH + 4) {
|
||||
container.style.height = `${required}px`;
|
||||
} else {
|
||||
container.style.height = '';
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/** 캔들 확대 보기 → 원복 직후 가격·시간축·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 reloadAll = useCallback(async (
|
||||
mgr: ChartManager,
|
||||
newBars: OHLCVBar[],
|
||||
ct: ChartType,
|
||||
th: Theme,
|
||||
ls: boolean,
|
||||
inds: IndicatorConfig[],
|
||||
) => {
|
||||
if (newBars.length === 0) return;
|
||||
|
||||
barsRef.current = newBars;
|
||||
prevChartType.current = ct;
|
||||
prevTheme.current = th;
|
||||
prevLogScale.current = ls;
|
||||
prevBarsKey.current = barsKey(newBars);
|
||||
prevIndKey.current = indKey(inds);
|
||||
prevSortedPKRef.current = sortedParamKey(inds);
|
||||
|
||||
mgr.setData(newBars, ct, th);
|
||||
mgr.setTheme(th);
|
||||
mgr.setLogScale(ls);
|
||||
mgr.setDisplayTimezone(displayTimezone, timeframe);
|
||||
|
||||
// 인디케이터를 순차적으로 등록 (async/await 보장)
|
||||
for (const ind of inds) {
|
||||
await mgr.addIndicator(ind);
|
||||
}
|
||||
|
||||
// 인디케이터 추가 완료 후 RAF × 2 대기
|
||||
// → LWC 가 모든 pane 을 DOM 에 확정한 뒤 높이·X축 재설정
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
applyPaneLayout(mgr); // ① pane 높이 재배분 (wrapper 높이 기준, 0이면 자동 재시도)
|
||||
requestAnimationFrame(() => {
|
||||
const futureBars = mgr.hasIchimoku() ? 28 : 0;
|
||||
mgr.setInitialVisibleRange(DISPLAY_COUNT, futureBars);
|
||||
resolve();
|
||||
});
|
||||
})));
|
||||
|
||||
// 데이터 로드 완료 알림: 멀티차트 sync range 재적용 등 외부 콜백 처리용
|
||||
onDataLoaded?.();
|
||||
|
||||
// ── 안전망: 멀티레이아웃에서 CSS Grid 높이 확정이 늦어진 경우를 위한 지연 재적용
|
||||
// applyPaneLayout 이 이미 재시도 중이지만, setInitialVisibleRange 도 재실행 필요할 수 있음
|
||||
const safetyTimers = [300, 700, 1400].map(delay =>
|
||||
setTimeout(() => {
|
||||
const m = managerRef.current;
|
||||
if (!m || !m.hasMainSeries()) 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);
|
||||
}, delay)
|
||||
);
|
||||
// cleanup 시 타이머 해제 (컴포넌트 언마운트 대응)
|
||||
// 반환값이 없으므로 managerRef 체크로 충분
|
||||
void safetyTimers; // 타이머는 managerRef null 체크로 자동 무효화됨
|
||||
}, [applyPaneLayout, onDataLoaded]);
|
||||
|
||||
// ── 차트 초기화 (마운트 시 1회) ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const mgr = new ChartManager(containerRef.current, theme);
|
||||
managerRef.current = mgr;
|
||||
setChartMgr(mgr);
|
||||
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;
|
||||
|
||||
const m = managerRef.current;
|
||||
if (!m) return;
|
||||
|
||||
// 컨테이너가 처음으로 유효한 크기를 가질 때:
|
||||
// 데이터가 이미 barsRef에 있지만 차트에 아직 세팅되지 않은 경우 (예: 멀티레이아웃 초기 렌더) 재로드
|
||||
if (barsRef.current.length > 0 && !m.hasMainSeries()) {
|
||||
reloadAll(
|
||||
m,
|
||||
barsRef.current,
|
||||
prevChartType.current,
|
||||
prevTheme.current,
|
||||
prevLogScale.current,
|
||||
[] // indicators는 별도 useEffect에서 처리
|
||||
);
|
||||
} else {
|
||||
applyPaneLayout(m);
|
||||
// 멀티→단일 레이아웃 전환처럼 숨겨졌다가 다시 표시될 때
|
||||
// setInitialVisibleRange 를 재실행해 가시 범위(캔들 분포)를 정상화
|
||||
if (wasHidden && m.hasMainSeries()) {
|
||||
requestAnimationFrame(() => {
|
||||
const fb = m.hasIchimoku() ? 28 : 0;
|
||||
m.setInitialVisibleRange(DISPLAY_COUNT, fb);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
ro.observe(containerRef.current);
|
||||
|
||||
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;
|
||||
}
|
||||
ro.disconnect();
|
||||
managerRef.current?.destroy();
|
||||
managerRef.current = null;
|
||||
setChartMgr(null);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
managerRef.current?.setDisplayTimezone(displayTimezone, timeframe);
|
||||
}, [displayTimezone, timeframe]);
|
||||
|
||||
// ── 데이터/인디케이터 동기화 ─────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr || !chartMgr || bars.length === 0) return;
|
||||
|
||||
// manager 준비 전 bars 가 먼저 도착한 경우(로그인 직후 등) 메인 시리즈 누락 방지
|
||||
if (!mgr.hasMainSeries()) {
|
||||
void reloadAll(mgr, bars, chartType, theme, logScale, indicators);
|
||||
return;
|
||||
}
|
||||
|
||||
const bk = barsKey(bars);
|
||||
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;
|
||||
|
||||
if (!barsChanged && !indChanged && !ctChanged && !thChanged && !lsChanged) return;
|
||||
|
||||
if (barsChanged || ctChanged || thChanged) {
|
||||
// 데이터 또는 차트 형식 변경 → 전체 재로드
|
||||
reloadAll(mgr, bars, chartType, theme, logScale, indicators);
|
||||
} else if (lsChanged) {
|
||||
prevLogScale.current = logScale;
|
||||
mgr.setLogScale(logScale);
|
||||
} else if (indChanged) {
|
||||
const prevPK = prevIndKey.current.split('@@')[0] ?? '';
|
||||
const currPK = paramKey(indicators);
|
||||
const prevSK = prevIndKey.current.split('@@')[1] ?? '';
|
||||
const currSK = styleKey(indicators);
|
||||
|
||||
const needsRecalc = prevPK !== currPK;
|
||||
// 같은 지표 집합인데 순서만 바뀐 경우 (reorder-only)
|
||||
const isReorderOnly = needsRecalc
|
||||
&& sortedParamKey(indicators) === prevSortedPKRef.current
|
||||
&& currSK === prevSK;
|
||||
|
||||
if (isReorderOnly) {
|
||||
// ── pane 순서 변경만: 메인 차트를 건드리지 않고 지표만 재배치 ──────────
|
||||
// 1) 지표 영역만 커버 (순간 숨김) → 2) 지표만 재추가 → 3) 서서히 공개
|
||||
prevSortedPKRef.current = sortedParamKey(indicators);
|
||||
prevIndKey.current = ik;
|
||||
|
||||
const containerEl = containerRef.current;
|
||||
// 지표 영역 위에 불투명 커버 div 삽입
|
||||
let cover: HTMLDivElement | null = null;
|
||||
if (containerEl) {
|
||||
cover = document.createElement('div');
|
||||
cover.style.cssText = [
|
||||
'position:absolute', 'inset:0', 'z-index:900',
|
||||
'pointer-events:none', 'opacity:1',
|
||||
'background:var(--bg,#131722)',
|
||||
].join(';');
|
||||
containerEl.parentElement?.appendChild(cover);
|
||||
}
|
||||
|
||||
mgr.reloadIndicatorsOnly(indicators).then(() => {
|
||||
if (candleOnlyModeRef.current) {
|
||||
mgr.applyCandleOnlyLayout(true, wrapperRef.current?.clientHeight);
|
||||
}
|
||||
applyPaneLayout(mgr);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (cover) {
|
||||
cover.style.transition = 'opacity 0.25s ease';
|
||||
cover.style.opacity = '0';
|
||||
setTimeout(() => { cover?.remove(); }, 300);
|
||||
}
|
||||
}));
|
||||
});
|
||||
} else if (needsRecalc) {
|
||||
// ── 지표 추가·제거·파라미터 변경 → 지표만 재로드 (메인 캔들 유지) ──
|
||||
// reloadIndicatorsOnly 는 메인/볼륨 시리즈를 건드리지 않으므로
|
||||
// 차트 위치 초기화·깜빡임 없이 지표만 갱신된다.
|
||||
prevSortedPKRef.current = sortedParamKey(indicators);
|
||||
prevIndKey.current = ik;
|
||||
|
||||
// 현재 scroll/zoom 위치를 저장해 reload 후 복원
|
||||
const savedLR = mgr.getVisibleLogicalRange();
|
||||
|
||||
mgr.reloadIndicatorsOnly(indicators).then(() => {
|
||||
if (candleOnlyModeRef.current) {
|
||||
mgr.applyCandleOnlyLayout(true, wrapperRef.current?.clientHeight);
|
||||
} else if (mgr.isCandleOnlyLayout()) {
|
||||
mgr.restoreFromCandleFullscreen(wrapperRef.current?.clientHeight);
|
||||
}
|
||||
applyPaneLayout(mgr);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (savedLR) mgr.applyVisibleLogicalRange(savedLR.from, savedLR.to);
|
||||
}));
|
||||
});
|
||||
} else if (prevSK !== currSK) {
|
||||
// ── 스타일만 변경 (색상·선폭·plotVisibility) → in-place 업데이트 ────
|
||||
// 시리즈를 제거·재생성하지 않으므로 pane 재번호 없음
|
||||
for (const ind of indicators) {
|
||||
mgr.applyIndicatorStyle(ind);
|
||||
}
|
||||
prevIndKey.current = ik;
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chartMgr, bars, chartType, theme, logScale, indicators]);
|
||||
|
||||
// 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 && (
|
||||
<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 && (
|
||||
<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 && (
|
||||
<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 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 barsKey(bars: OHLCVBar[]): string {
|
||||
if (bars.length === 0) return '';
|
||||
const last = bars[bars.length - 1];
|
||||
// 종목이 달라도 시간 범위·개수가 같을 수 있으므로 마지막 close 가격도 포함
|
||||
return `${bars.length}:${bars[0].time}:${last.time}:${last.close}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 재계산이 필요한 변경 키 (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'}`).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 ?? {})}`
|
||||
).join(';');
|
||||
}
|
||||
|
||||
/** 전체 변경 감지 키 (두 키를 합침) */
|
||||
function indKey(inds: IndicatorConfig[]): string {
|
||||
return paramKey(inds) + '@@' + styleKey(inds);
|
||||
}
|
||||
|
||||
export default TradingChart;
|
||||
Reference in New Issue
Block a user