1182 lines
50 KiB
TypeScript
1182 lines
50 KiB
TypeScript
/**
|
|
* ChartSlot
|
|
* 레이아웃 내 개별 차트 슬롯. 자체 심볼/타임프레임/데이터를 관리한다.
|
|
*/
|
|
import React, {
|
|
useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo,
|
|
useImperativeHandle, forwardRef,
|
|
} from 'react';
|
|
import ReactDOM from 'react-dom';
|
|
import TradingChart from './TradingChart';
|
|
import IndicatorContextToolbar from './IndicatorContextToolbar';
|
|
import IndicatorSettingsModal from './IndicatorSettingsModal';
|
|
import MainChartSettingsModal from './MainChartSettingsModal';
|
|
import { MarketSearchPanel } from './MarketSearchPanel';
|
|
import { generateBars, formatPrice } from '../utils/dataGenerator';
|
|
import { getKoreanName } from '../utils/marketNameCache';
|
|
import type { TickerData } from '../hooks/useMarketTicker';
|
|
import {
|
|
formatChartLiveChangePct,
|
|
formatChartLivePrice,
|
|
resolveChartLiveQuote,
|
|
} from '../utils/chartLiveQuote';
|
|
import { DEFAULT_MAIN_CHART_STYLE } from '../utils/storage';
|
|
import { enrichIndicatorConfig, getIndicatorDef } from '../utils/indicatorRegistry';
|
|
import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from '../utils/smaConfig';
|
|
import { normalizeIchimokuConfig } from '../utils/ichimokuConfig';
|
|
import { isUpbitMarket } from '../utils/upbitApi';
|
|
import { useChartRealtimeData, type WsStatus } from '../hooks/useChartRealtimeData';
|
|
import { useLiveReceiveFlash } from '../hooks/useLiveReceiveFlash';
|
|
import VirtualLiveBadge from './virtual/VirtualLiveBadge';
|
|
import type { VirtualLiveStatus } from '../hooks/useVirtualTargetLiveStatus';
|
|
import { invalidateMarketCache } from '../utils/requestCache';
|
|
import { DISPLAY_COUNT } from '../hooks/useUpbitData';
|
|
import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader';
|
|
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
|
|
import { markChartIndicatorSaveCommitted } from '../utils/indicatorChartDefaultsSync';
|
|
import {
|
|
duplicateIndicatorInList,
|
|
mergeIndicators,
|
|
reorderIndicatorGroup,
|
|
removeIndicatorFromList,
|
|
removeIndicatorTypeFromList,
|
|
splitIndicatorPane,
|
|
replaceIndicatorConfigsFromTypes,
|
|
} from '../utils/indicatorPaneMerge';
|
|
import { sortIndicatorsByTypeOrder } from '../utils/indicatorListOrder';
|
|
import {
|
|
type ChartOverlayToggleKey,
|
|
type ChartOverlayVisibility,
|
|
chartOverlayKeyForType,
|
|
DEFAULT_CHART_OVERLAY_VISIBILITY,
|
|
listChartOverlayToggleItems,
|
|
} from '../utils/chartOverlayVisibility';
|
|
import {
|
|
type ChartCustomOverlaySelection,
|
|
type ChartCustomOverlaySlotId,
|
|
createDefaultChartCustomOverlaySelection,
|
|
isCustomOverlaySlotActive,
|
|
resolveActiveCustomOverlaySlot,
|
|
syncChartManagerCustomOverlaysByActiveSlot,
|
|
} from '../utils/chartCustomOverlay';
|
|
import ChartCustomOverlaySettingsModal from './ChartCustomOverlaySettingsModal';
|
|
import { saveSlot } from '../utils/backendApi';
|
|
import type {
|
|
OHLCVBar, ChartType, Theme, Timeframe,
|
|
IndicatorConfig, LegendData, Drawing, ChartMode, MainChartStyle,
|
|
} from '../types';
|
|
import type { ChartManager } from '../utils/ChartManager';
|
|
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
|
|
|
|
// ── 타임프레임 옵션 ────────────────────────────────────────────────────────
|
|
const TF_OPTIONS: Timeframe[] = ['1m','3m','5m','10m','15m','30m','1h','4h','1D','1W','1M'];
|
|
|
|
function compactKoName(market: string): string {
|
|
const ko = getKoreanName(market);
|
|
if (ko && ko !== market) return ko;
|
|
return market.replace(/^KRW-/, '');
|
|
}
|
|
|
|
function wsToLiveStatus(status: WsStatus): VirtualLiveStatus {
|
|
switch (status) {
|
|
case 'connected': return 'live';
|
|
case 'connecting': return 'connecting';
|
|
case 'disconnected':
|
|
case 'error':
|
|
return 'disconnected';
|
|
default:
|
|
return 'idle';
|
|
}
|
|
}
|
|
|
|
// ── Ws 배지 ───────────────────────────────────────────────────────────────
|
|
const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isUpbit }) => {
|
|
if (!isUpbit) return null;
|
|
const map: Record<WsStatus, { dot: string; label: string }> = {
|
|
connecting: { dot: 'dot-yellow', label: '연결 중...' },
|
|
connected: { dot: 'dot-lime', label: '실시간' },
|
|
disconnected: { dot: 'dot-red', label: '재연결' },
|
|
error: { dot: 'dot-red', label: '오류' },
|
|
};
|
|
const { dot, label } = map[status];
|
|
return (
|
|
<span className={`ws-badge ${dot}`} style={{ fontSize: '10px' }}>
|
|
<span className="ws-dot" />{label}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
let _slotIndCounter = 0;
|
|
function newIndId() { return `sind_${++_slotIndCounter}_${Date.now()}`; }
|
|
|
|
// ── Ref 핸들 (외부에서 슬롯 조작용) ─────────────────────────────────────────
|
|
export interface ChartSlotHandle {
|
|
/** 지표 추가 */
|
|
addIndicator: (type: string, fromConfig?: IndicatorConfig) => void;
|
|
/** 지표 일괄 추가 (탭 전체 추가 — 기존 지표 제거 후 탭 지표만 적용) */
|
|
addIndicators: (types: string[]) => void;
|
|
/** 지표 제거 (type 기준) */
|
|
removeIndicatorByType: (type: string) => void;
|
|
/** 보조지표 pane 복사 */
|
|
duplicateIndicator: (sourceId: string) => void;
|
|
/** 현재 활성 심볼 */
|
|
getSymbol: () => string;
|
|
/** 현재 활성 타임프레임 */
|
|
getTimeframe: () => Timeframe;
|
|
/** 현재 활성 지표 목록 */
|
|
getIndicators: () => IndicatorConfig[];
|
|
/** 심볼 변경 */
|
|
setSymbol: (s: string) => void;
|
|
/** 타임프레임 변경 */
|
|
setTimeframe: (tf: Timeframe) => void;
|
|
/** 지표 목록 교체 (모드 전환 시 이전 설정 복원용) */
|
|
setIndicators: (inds: IndicatorConfig[]) => void;
|
|
/** 크로스헤어 자석모드 설정 */
|
|
setCrosshairMagnet: (enabled: boolean) => void;
|
|
/** 가격축 라벨·설명 on/off (캔들·보조지표 동일 값) */
|
|
setSeriesPriceLabelsEnabled: (enabled: boolean) => void;
|
|
setCandleAreaPriceLabelsEnabled: (enabled: boolean) => void;
|
|
setIndicatorAreaPriceLabelsEnabled: (enabled: boolean) => void;
|
|
/** 거래량 pane on/off */
|
|
setVolumeVisible: (visible: boolean) => void;
|
|
setPaneSeparatorOptions: (opts: ChartPaneSeparatorOptions) => void;
|
|
/** 차트 시간축·크로스헤어 날짜 형식 */
|
|
setChartTimeFormat: (format: string, timeframe?: Timeframe) => void;
|
|
}
|
|
|
|
// ── Props ─────────────────────────────────────────────────────────────────
|
|
export interface ChartSlotProps {
|
|
slotIndex: number;
|
|
theme: Theme;
|
|
/** 슬롯이 활성(포커스) 상태인지 */
|
|
active: boolean;
|
|
onActivate: () => void;
|
|
/** 외부에서 강제 심볼 동기화 */
|
|
forcedSymbol?: string;
|
|
/** 외부에서 강제 타임프레임 동기화 */
|
|
forcedTf?: Timeframe;
|
|
/** 마운트 시 1회만 적용되는 초기 심볼 (단일↔다중 모드 전환 시 설정 이전용) */
|
|
initialSymbol?: string;
|
|
/** 마운트 시 1회만 적용되는 초기 타임프레임 */
|
|
initialTimeframe?: Timeframe;
|
|
/** 마운트 시 1회만 적용되는 초기 지표 목록 */
|
|
initialIndicators?: IndicatorConfig[];
|
|
/** Crosshair sync: 동기화 시각 (unix seconds) */
|
|
syncTime?: number;
|
|
onCrosshairTime?: (t: number) => void;
|
|
/** Time sync: 동기화 가시 시간 범위 */
|
|
syncVisibleRange?: { from: number; to: number } | null;
|
|
onVisibleRangeChange?: (r: { from: number; to: number }) => void;
|
|
/** Date range sync: 동기화 가시 논리 범위 */
|
|
syncLogicalRange?: { from: number; to: number } | null;
|
|
onLogicalRangeChange?: (r: { from: number; to: number }) => void;
|
|
/** 돋보기 활성 여부 (App 에서 전달) */
|
|
magnifierEnabled?: boolean;
|
|
/** 돋보기 닫기 콜백 */
|
|
onMagnifierClose?: () => void;
|
|
/** 멀티차트 전체 보기: 이 슬롯을 단일 차트로 확장 */
|
|
onFullView?: (symbol: string) => void;
|
|
/**
|
|
* 슬롯 내 지표 목록이 바뀔 때마다 호출.
|
|
* App 에서 활성 슬롯의 지표 목록을 IndicatorModal 에 반영하기 위해 사용.
|
|
*/
|
|
onIndicatorsChange?: (indicators: IndicatorConfig[]) => void;
|
|
/**
|
|
* 슬롯 내 심볼이 바뀔 때마다 호출.
|
|
* App 에서 activeSlotSymbol(호가창 심볼)을 동기화하기 위해 사용.
|
|
*/
|
|
onSymbolChange?: (symbol: string) => void;
|
|
/** 자석모드 상태 (크로스헤어 스냅) */
|
|
magnetMode?: 'off' | 'weak' | 'strong';
|
|
/** 차트 우클릭 → 매수·매도 패널 자동 입력 */
|
|
onTradeOrderRequest?: (req: { market: string; price: number; side: 'buy' | 'sell' }, slotIndex: number) => void;
|
|
/** 캔들 영역 오버레이 지표 가격축 라벨·설명 */
|
|
chartCandleAreaPriceLabels?: boolean;
|
|
/** 하단 보조지표 pane 가격축 라벨·설명 */
|
|
chartSeriesPriceLabels?: boolean;
|
|
/** 차트 하단 거래량 바 표시 */
|
|
chartVolumeVisible?: boolean;
|
|
chartRealtimeSource?: 'BACKEND_STOMP' | 'UPBIT_DIRECT';
|
|
displayTimezone?: string;
|
|
/** 멀티차트 — 가상투자 카드 차트처럼 컴팩트 카드 UI */
|
|
compactMode?: boolean;
|
|
/** 실시간 수신 시 카드 아웃라인 형광 연두 음영 (설정 연동) */
|
|
chartLiveReceiveHighlight?: boolean;
|
|
/** 실시간 차트 화면 표시 여부 (설정 등 다른 메뉴에서 숨김 시 false) */
|
|
chartVisible?: boolean;
|
|
/** pane 구분선 */
|
|
chartPaneSeparator?: ChartPaneSeparatorOptions;
|
|
/** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 */
|
|
chartCrosshairInfoVisible?: boolean;
|
|
/** 차트 마우스오버 줌·이동 플로팅 툴바 */
|
|
chartHoverToolbarVisible?: boolean;
|
|
/** 업비트 실시간 티커 맵 (슬롯 심볼별 현재가·등락) */
|
|
marketTickers?: Map<string, TickerData>;
|
|
}
|
|
|
|
const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot({
|
|
slotIndex, theme, active, onActivate,
|
|
forcedSymbol, forcedTf,
|
|
initialSymbol, initialTimeframe, initialIndicators,
|
|
syncTime, onCrosshairTime,
|
|
syncVisibleRange, onVisibleRangeChange,
|
|
syncLogicalRange, onLogicalRangeChange,
|
|
onFullView,
|
|
magnifierEnabled = false,
|
|
onMagnifierClose,
|
|
onIndicatorsChange,
|
|
onSymbolChange,
|
|
magnetMode = 'off',
|
|
onTradeOrderRequest,
|
|
chartCandleAreaPriceLabels = true,
|
|
chartSeriesPriceLabels = true,
|
|
chartVolumeVisible = true,
|
|
chartRealtimeSource = 'BACKEND_STOMP',
|
|
displayTimezone,
|
|
compactMode = false,
|
|
chartLiveReceiveHighlight = true,
|
|
chartVisible = true,
|
|
chartPaneSeparator,
|
|
chartCrosshairInfoVisible = true,
|
|
chartHoverToolbarVisible = true,
|
|
marketTickers,
|
|
}, ref) {
|
|
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
|
|
const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings();
|
|
|
|
// ── 자체 상태 ─────────────────────────────────────────────────────────
|
|
// initial* 값이 있으면 우선 적용 (단일↔다중 모드 전환 시 설정 이전용)
|
|
const [symbol, setSymbol] = useState(initialSymbol ?? forcedSymbol ?? 'KRW-BTC');
|
|
const [timeframe, setTimeframe] = useState<Timeframe>(initialTimeframe ?? forcedTf ?? '1D');
|
|
const [chartType] = useState<ChartType>('candlestick');
|
|
const [indicators, setIndicators] = useState<IndicatorConfig[]>(initialIndicators ?? []);
|
|
const [drawings] = useState<Drawing[]>([]);
|
|
const [legend, setLegend] = useState<LegendData | null>(null);
|
|
const [ctxToolbar, setCtxToolbar] = useState<{ entryId: string; screenX: number; screenY: number } | null>(null);
|
|
const [settingsModalId, setSettingsModalId] = useState<string | null>(null);
|
|
const [showMainChartModal, setShowMainChartModal] = useState(false);
|
|
const [mainChartStyle, setMainChartStyle] = useState<MainChartStyle>(DEFAULT_MAIN_CHART_STYLE);
|
|
const [showMarket, setShowMarket] = useState(false);
|
|
// MarketSearchPanel 위치: compact=종목명 버튼, 일반=슬롯 전체
|
|
const [slotAnchorRect, setSlotAnchorRect] = useState<DOMRect | null>(null);
|
|
|
|
const managerRef = useRef<ChartManager | null>(null);
|
|
const compactNameRef = useRef<HTMLButtonElement>(null);
|
|
// initialSymbol 도 초기값으로 포함: requestAnimationFrame 이전에 올바른 값 보장
|
|
const symbolRef = useRef(initialSymbol ?? forcedSymbol ?? 'KRW-BTC');
|
|
const timeframeRef = useRef<Timeframe>(forcedTf ?? '1D');
|
|
|
|
// symbol / timeframe 변경 시 ref 동기화 (외부 접근용)
|
|
useEffect(() => { symbolRef.current = symbol; }, [symbol]);
|
|
useEffect(() => { timeframeRef.current = timeframe; }, [timeframe]);
|
|
|
|
// 심볼 변경 시 부모(App)에 알림 → 호가창 activeSlotSymbol 동기화
|
|
const onSymbolChangeRef = useRef(onSymbolChange);
|
|
onSymbolChangeRef.current = onSymbolChange;
|
|
useEffect(() => {
|
|
onSymbolChangeRef.current?.(symbol);
|
|
}, [symbol]);
|
|
|
|
// 지표 목록 변경 시 부모(App)에 알림 → IndicatorModal 의 activeIndicators 갱신
|
|
const onIndicatorsChangeRef = useRef(onIndicatorsChange);
|
|
onIndicatorsChangeRef.current = onIndicatorsChange;
|
|
useEffect(() => {
|
|
onIndicatorsChangeRef.current?.(indicators);
|
|
}, [indicators]);
|
|
|
|
// indicators 를 ref 로도 유지 (useImperativeHandle 의 최신 값 접근용)
|
|
const indicatorsRef = useRef<IndicatorConfig[]>(indicators);
|
|
useEffect(() => { indicatorsRef.current = indicators; }, [indicators]);
|
|
|
|
// ── 외부 핸들 노출 ─────────────────────────────────────────────────────
|
|
useImperativeHandle(ref, () => ({
|
|
addIndicator: (type: string, fromConfig?: IndicatorConfig) => {
|
|
setIndicators(prev => {
|
|
if (prev.some(i => i.type === type)) return prev;
|
|
const def = getIndicatorDef(type);
|
|
if (!def) return prev;
|
|
let cfg: IndicatorConfig;
|
|
if (fromConfig) {
|
|
cfg = { ...fromConfig, id: newIndId(), type: def.type };
|
|
} else {
|
|
const params = getParams(type, def.defaultParams);
|
|
const { plots, hlines, cloudColors, bandBackground } = getVisualConfig(type, def.plots, def.hlines);
|
|
cfg = {
|
|
id: newIndId(),
|
|
type: def.type,
|
|
params,
|
|
plots,
|
|
hlines,
|
|
...(cloudColors ? { cloudColors } : {}),
|
|
...(bandBackground ? { bandBackground } : {}),
|
|
};
|
|
}
|
|
if (type === 'SMA') {
|
|
cfg = normalizeSmaConfig({
|
|
...cfg,
|
|
plotVisibility: cfg.plotVisibility ?? createDefaultSmaPlotVisibility(),
|
|
});
|
|
} else if (type === 'IchimokuCloud') {
|
|
cfg = normalizeIchimokuConfig(cfg);
|
|
}
|
|
return sortIndicatorsByTypeOrder([...prev, cfg]);
|
|
});
|
|
},
|
|
addIndicators: (types: string[]) => {
|
|
if (!types.length) return;
|
|
setIndicators(
|
|
sortIndicatorsByTypeOrder(
|
|
replaceIndicatorConfigsFromTypes(types, getParams, getVisualConfig, newIndId),
|
|
types,
|
|
),
|
|
);
|
|
},
|
|
removeIndicatorByType: (type: string) => {
|
|
setIndicators(prev => removeIndicatorTypeFromList(prev, type));
|
|
},
|
|
duplicateIndicator: (sourceId: string) => {
|
|
setIndicators(prev => duplicateIndicatorInList(sourceId, prev, newIndId) ?? prev);
|
|
},
|
|
getSymbol: () => symbolRef.current,
|
|
getTimeframe: () => timeframeRef.current,
|
|
getIndicators: () => indicatorsRef.current,
|
|
setSymbol: (s: string) => {
|
|
pendingRealtimeBarRef.current = null;
|
|
// 새 종목의 캐시를 미리 무효화해 신선한 데이터를 요청하도록 함
|
|
if (isUpbitMarket(s)) invalidateMarketCache(s);
|
|
setSymbol(s);
|
|
setLegend(null);
|
|
},
|
|
setTimeframe: (tf: Timeframe) => { setTimeframe(tf); },
|
|
setIndicators: (inds: IndicatorConfig[]) => { setIndicators(inds); },
|
|
setCrosshairMagnet: (enabled: boolean) => {
|
|
managerRef.current?.setCrosshairMagnet(enabled);
|
|
},
|
|
setSeriesPriceLabelsEnabled: (enabled: boolean) => {
|
|
managerRef.current?.setSeriesPriceLabelsEnabled(enabled);
|
|
},
|
|
setCandleAreaPriceLabelsEnabled: (enabled: boolean) => {
|
|
managerRef.current?.setCandleAreaPriceLabelsEnabled(enabled);
|
|
},
|
|
setIndicatorAreaPriceLabelsEnabled: (enabled: boolean) => {
|
|
managerRef.current?.setIndicatorAreaPriceLabelsEnabled(enabled);
|
|
},
|
|
setVolumeVisible: (visible: boolean) => {
|
|
managerRef.current?.setVolumeVisible(visible);
|
|
},
|
|
setPaneSeparatorOptions: (opts: ChartPaneSeparatorOptions) => {
|
|
managerRef.current?.setPaneSeparatorOptions(opts);
|
|
managerRef.current?.refreshPaneSeparatorOverlay();
|
|
},
|
|
setChartTimeFormat: (format: string, tf?: Timeframe) => {
|
|
managerRef.current?.setChartTimeFormat(format);
|
|
if (tf) managerRef.current?.setDisplayTimezone(displayTimezone ?? 'Asia/Seoul', tf, format);
|
|
},
|
|
}), [displayTimezone]);
|
|
|
|
// 자석모드 변경 → ChartManager 크로스헤어 모드 즉시 반영
|
|
useEffect(() => {
|
|
managerRef.current?.setCrosshairMagnet(magnetMode !== 'off');
|
|
}, [magnetMode]);
|
|
|
|
useEffect(() => {
|
|
managerRef.current?.setCandleAreaPriceLabelsEnabled(chartCandleAreaPriceLabels);
|
|
}, [chartCandleAreaPriceLabels]);
|
|
|
|
useEffect(() => {
|
|
managerRef.current?.setIndicatorAreaPriceLabelsEnabled(chartSeriesPriceLabels);
|
|
}, [chartSeriesPriceLabels]);
|
|
|
|
useEffect(() => {
|
|
managerRef.current?.setVolumeVisible(chartVolumeVisible);
|
|
}, [chartVolumeVisible]);
|
|
|
|
useEffect(() => {
|
|
if (!chartPaneSeparator) return;
|
|
managerRef.current?.setPaneSeparatorOptions(chartPaneSeparator);
|
|
}, [chartPaneSeparator]);
|
|
|
|
// 외부 심볼 동기화
|
|
useEffect(() => {
|
|
if (forcedSymbol && forcedSymbol !== symbol) setSymbol(forcedSymbol);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [forcedSymbol]);
|
|
|
|
// 외부 타임프레임 동기화
|
|
useEffect(() => {
|
|
if (forcedTf && forcedTf !== timeframe) setTimeframe(forcedTf);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [forcedTf]);
|
|
|
|
// mainChartStyle → ChartManager
|
|
useEffect(() => {
|
|
managerRef.current?.updateMainSeriesStyle(mainChartStyle);
|
|
}, [mainChartStyle]);
|
|
|
|
// ── 슬롯 상태 변경 시 DB 자동 저장 (디바운스 2s) ─────────────────────────
|
|
// 멀티슬롯 모드에서 각 슬롯 독립 저장 보장 (App.tsx workspaceState는 슬롯 0만 포함)
|
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
useEffect(() => {
|
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
|
saveTimerRef.current = setTimeout(() => {
|
|
saveSlot(slotIndex, {
|
|
slotIndex,
|
|
symbol,
|
|
timeframe,
|
|
chartType: 'candlestick',
|
|
mode: 'normal',
|
|
logScale: false,
|
|
drawingsLocked: false,
|
|
drawingsVisible: true,
|
|
indicators,
|
|
drawings: [],
|
|
paneLayout: null,
|
|
mainChartStyle,
|
|
}).catch(err => console.error('[ChartSlot] saveSlot failed', err));
|
|
}, 2000);
|
|
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); };
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [slotIndex, symbol, timeframe, indicators, mainChartStyle]);
|
|
|
|
// ── 동기화 콜백 refs (항상 최신 값을 참조하도록) ─────────────────────────
|
|
const onVisibleRangeChangeRef = useRef(onVisibleRangeChange);
|
|
const onLogicalRangeChangeRef = useRef(onLogicalRangeChange);
|
|
onVisibleRangeChangeRef.current = onVisibleRangeChange;
|
|
onLogicalRangeChangeRef.current = onLogicalRangeChange;
|
|
|
|
// 외부에서 sync 값을 적용하는 중임을 표시 (feedback loop 방지)
|
|
const isSyncingTimeRef = useRef(false);
|
|
const isSyncingLogicalRef = useRef(false);
|
|
|
|
// 데이터 로드 전 수신한 sync range 를 저장해두는 pending ref
|
|
// → onDataLoaded 콜백에서 재적용 (멀티차트 첫 마운트 시 타이밍 문제 해결)
|
|
const pendingSyncVisibleRef = useRef<{ from: number; to: number } | null>(null);
|
|
const pendingSyncLogicalRef = useRef<{ from: number; to: number } | null>(null);
|
|
/** 종목·TF 전환 직후 sync range 재적용 스킵 (이전 종목 뷰포트가 새 차트에 적용되는 것 방지) */
|
|
const skipSyncApplyRef = useRef(false);
|
|
|
|
// ── Crosshair sync: syncTime → setCrosshairAtTime ────────────────────
|
|
useEffect(() => {
|
|
if (!syncTime) { managerRef.current?.clearCrosshairPosition(); return; }
|
|
managerRef.current?.setCrosshairAtTime(syncTime);
|
|
}, [syncTime]);
|
|
|
|
// ── Time sync: syncVisibleRange 외부 적용 ────────────────────────────
|
|
useEffect(() => {
|
|
if (!syncVisibleRange || skipSyncApplyRef.current) return;
|
|
const mgr = managerRef.current;
|
|
if (!mgr || mgr.getRawBarsLength() === 0) {
|
|
// 데이터 로드 전 → pending 저장 후 onDataLoaded 에서 재적용
|
|
pendingSyncVisibleRef.current = syncVisibleRange;
|
|
return;
|
|
}
|
|
isSyncingTimeRef.current = true;
|
|
mgr.applyVisibleTimeRange(syncVisibleRange.from, syncVisibleRange.to);
|
|
requestAnimationFrame(() => { isSyncingTimeRef.current = false; });
|
|
}, [syncVisibleRange]);
|
|
|
|
// ── Date range sync: syncLogicalRange 외부 적용 ──────────────────────
|
|
useEffect(() => {
|
|
if (!syncLogicalRange || skipSyncApplyRef.current) return;
|
|
const mgr = managerRef.current;
|
|
if (!mgr || mgr.getRawBarsLength() === 0) {
|
|
pendingSyncLogicalRef.current = syncLogicalRange;
|
|
return;
|
|
}
|
|
isSyncingLogicalRef.current = true;
|
|
mgr.applyVisibleLogicalRange(syncLogicalRange.from, syncLogicalRange.to);
|
|
requestAnimationFrame(() => { isSyncingLogicalRef.current = false; });
|
|
}, [syncLogicalRange]);
|
|
|
|
// 멀티 레이아웃 새로고침 시 차트 빈 화면 방지:
|
|
// CSS Grid 높이 확정 지연에 대비해 여러 시점에 pane 높이를 재적용한다.
|
|
// TradingChart 자체도 applyPaneLayout 자동 재시도를 하지만, 슬롯 레벨에서도 보강.
|
|
// ※ TradingChart를 재마운트(key 변경)하면 managerRef가 초기화되어 MarketSearchPanel 등
|
|
// 다른 상태에 영향을 줄 수 있으므로, 재마운트 대신 resetPaneHeights 재시도만 수행한다.
|
|
const slotContainerRef = useRef<HTMLDivElement | null>(null);
|
|
// 데이터 로딩 완료 후 차트가 여전히 비어 있으면 1회만 재마운트 (data + blank guard)
|
|
const [reloadTick, setReloadTick] = useState(0);
|
|
const reloadTriggeredRef = useRef(false); // 재마운트는 최대 1회
|
|
useLayoutEffect(() => {
|
|
reloadTriggeredRef.current = false;
|
|
chartLiveReadyRef.current = false;
|
|
pendingRealtimeBarRef.current = null;
|
|
pendingSyncVisibleRef.current = null;
|
|
pendingSyncLogicalRef.current = null;
|
|
skipSyncApplyRef.current = true;
|
|
}, [symbol, timeframe]);
|
|
useEffect(() => {
|
|
const checks = [200, 500, 1000, 2000].map(delay =>
|
|
setTimeout(() => {
|
|
const el = slotContainerRef.current;
|
|
const mgr = managerRef.current;
|
|
if (!el || !mgr) return;
|
|
const h = el.clientHeight;
|
|
if (h <= 0) return;
|
|
if (mgr.hasMainSeries()) {
|
|
// 차트 데이터 있음 → pane 높이만 재적용
|
|
mgr.resetPaneHeights(h);
|
|
} else if (!reloadTriggeredRef.current) {
|
|
// 차트 비어 있고 아직 재시도 안 했음 → 1회만 재마운트
|
|
// (bars 로딩 지연일 수 있으므로, 늦은 타이머에서만 트리거)
|
|
if (delay >= 1000) {
|
|
reloadTriggeredRef.current = true;
|
|
setReloadTick(t => t + 1);
|
|
}
|
|
}
|
|
}, delay)
|
|
);
|
|
return () => checks.forEach(clearTimeout);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// ── 데이터 ────────────────────────────────────────────────────────────
|
|
const useUpbit = isUpbitMarket(symbol);
|
|
|
|
// 과거 데이터 추가 로드 (차트를 왼쪽으로 스크롤할 때)
|
|
const { isLoadingMore, loadMoreRef } = useHistoryLoader({
|
|
symbol, timeframe, isUpbit: useUpbit, managerRef,
|
|
});
|
|
|
|
const [simBars, setSimBars] = useState<OHLCVBar[]>(() =>
|
|
!isUpbitMarket(symbol) ? generateBars(symbol, timeframe) : []
|
|
);
|
|
useEffect(() => {
|
|
if (useUpbit) return;
|
|
setSimBars(generateBars(symbol, timeframe));
|
|
setLegend(null);
|
|
}, [symbol, timeframe, useUpbit]);
|
|
|
|
const pendingRealtimeBarRef = useRef<OHLCVBar | null>(null);
|
|
const barsMarketRef = useRef<string | null>(null);
|
|
const chartLiveReadyRef = useRef(false);
|
|
|
|
const handleCandlesReady = useCallback(() => {
|
|
chartLiveReadyRef.current = true;
|
|
const pending = pendingRealtimeBarRef.current;
|
|
const mgr = managerRef.current;
|
|
if (!pending || !mgr || barsMarketRef.current !== symbolRef.current) return;
|
|
pendingRealtimeBarRef.current = null;
|
|
mgr.updateBar(pending);
|
|
}, []);
|
|
|
|
const handleTickUpdate = useCallback((bar: OHLCVBar) => {
|
|
if (barsMarketRef.current !== symbolRef.current) return;
|
|
if (!chartLiveReadyRef.current) {
|
|
pendingRealtimeBarRef.current = bar;
|
|
return;
|
|
}
|
|
if (!managerRef.current) {
|
|
pendingRealtimeBarRef.current = bar;
|
|
return;
|
|
}
|
|
managerRef.current.updateBar(bar);
|
|
}, []);
|
|
const handleNewCandle = useCallback((bar: OHLCVBar) => {
|
|
if (barsMarketRef.current !== symbolRef.current) return;
|
|
if (!chartLiveReadyRef.current) {
|
|
pendingRealtimeBarRef.current = bar;
|
|
return;
|
|
}
|
|
if (!managerRef.current) {
|
|
pendingRealtimeBarRef.current = bar;
|
|
return;
|
|
}
|
|
managerRef.current.appendBar(bar);
|
|
}, []);
|
|
|
|
const { bars: upbitBars, barsMarket: upbitBarsMarket, latestBar, wsStatus, isLoading } = useChartRealtimeData(
|
|
symbol, timeframe,
|
|
useMemo(() => ({
|
|
onTickUpdate: handleTickUpdate,
|
|
onNewCandle: handleNewCandle,
|
|
enabled: useUpbit,
|
|
source: chartRealtimeSource,
|
|
}), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]),
|
|
);
|
|
|
|
const bars = useUpbit ? upbitBars : simBars;
|
|
const barsMarket = useUpbit ? upbitBarsMarket : symbol;
|
|
barsMarketRef.current = barsMarket;
|
|
|
|
const lastKnownBar = useUpbit
|
|
? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null))
|
|
: (bars.length > 0 ? bars[bars.length - 1] : null);
|
|
const quoteBar = lastKnownBar;
|
|
const slotTicker = marketTickers?.get(symbol) ?? null;
|
|
const liveQuote = useMemo(
|
|
() => resolveChartLiveQuote(slotTicker, quoteBar, bars),
|
|
[slotTicker, quoteBar, bars],
|
|
);
|
|
|
|
const receiveSignal = useMemo(() => {
|
|
if (!useUpbit || wsStatus !== 'connected' || !latestBar) return null;
|
|
return `${latestBar.time}|${latestBar.close}|${latestBar.volume}`;
|
|
}, [useUpbit, wsStatus, latestBar]);
|
|
|
|
const receiving = useLiveReceiveFlash(receiveSignal, compactMode && useUpbit && wsStatus === 'connected');
|
|
const highlightReceiving = chartLiveReceiveHighlight && receiving;
|
|
|
|
// ── 인디케이터 핸들러 ─────────────────────────────────────────────────
|
|
const handleIndicatorSave = useCallback((updated: IndicatorConfig) => {
|
|
setIndicators(prev => prev.map(i => i.id === updated.id ? updated : i));
|
|
setSettingsModalId(null);
|
|
setCtxToolbar(null);
|
|
markChartIndicatorSaveCommitted();
|
|
// 파라미터 변경 DB 저장 → 백엔드 Ta4j 계산에 반영
|
|
saveParams(updated.type, updated.params);
|
|
// 시각 설정(색상·선굵기·수평선) 변경 DB 저장 → 새 지표 추가 시 기본값으로 사용
|
|
saveVisual(
|
|
updated.type,
|
|
updated.plots,
|
|
updated.hlines,
|
|
updated.cloudColors,
|
|
updated.bandBackground,
|
|
);
|
|
}, [saveParams, saveVisual]);
|
|
|
|
const handleToggleHidden = useCallback((id: string) => {
|
|
setIndicators(prev => prev.map(i => i.id === id ? { ...i, hidden: !i.hidden } : i));
|
|
}, []);
|
|
|
|
const handleRemoveById = useCallback((id: string) => {
|
|
setIndicators(prev => removeIndicatorFromList(prev, id));
|
|
setCtxToolbar(null);
|
|
setSettingsModalId(null);
|
|
}, []);
|
|
|
|
const handleReorderIndicators = useCallback((fromId: string, insertBeforeId: string | null) => {
|
|
setIndicators(prev => reorderIndicatorGroup(fromId, insertBeforeId, prev));
|
|
}, []);
|
|
|
|
const handleMergeIndicators = useCallback((fromId: string, intoId: string) => {
|
|
setIndicators(prev => mergeIndicators(fromId, intoId, prev));
|
|
}, []);
|
|
|
|
const handleSplitIndicatorPane = useCallback((hostId: string) => {
|
|
setIndicators(prev => splitIndicatorPane(hostId, prev));
|
|
}, []);
|
|
|
|
const [focusedIndicatorId, setFocusedIndicatorId] = useState<string | null>(null);
|
|
|
|
const handleExpandIndicator = useCallback((id: string) => {
|
|
setFocusedIndicatorId(id);
|
|
}, []);
|
|
|
|
const handleRestoreIndicators = useCallback(() => {
|
|
setFocusedIndicatorId(null);
|
|
}, []);
|
|
|
|
const [overlayVisibility, setOverlayVisibility] = useState<ChartOverlayVisibility>(
|
|
() => ({ ...DEFAULT_CHART_OVERLAY_VISIBILITY }),
|
|
);
|
|
const overlayVisibilityRef = useRef(overlayVisibility);
|
|
useEffect(() => {
|
|
overlayVisibilityRef.current = overlayVisibility;
|
|
}, [overlayVisibility]);
|
|
|
|
const candleOverlayToggles = useMemo(
|
|
() => listChartOverlayToggleItems(overlayVisibility),
|
|
[overlayVisibility],
|
|
);
|
|
|
|
const [customOverlaySelection, setCustomOverlaySelection] = useState<ChartCustomOverlaySelection>(
|
|
() => createDefaultChartCustomOverlaySelection(),
|
|
);
|
|
const [custom1OverlaySelection, setCustom1OverlaySelection] = useState<ChartCustomOverlaySelection>(
|
|
() => createDefaultChartCustomOverlaySelection(),
|
|
);
|
|
const [activeCustomOverlaySlot, setActiveCustomOverlaySlot] = useState<ChartCustomOverlaySlotId | null>(null);
|
|
const [customOverlaySettingsSlot, setCustomOverlaySettingsSlot] = useState<ChartCustomOverlaySlotId | null>(null);
|
|
const customOverlayActive = isCustomOverlaySlotActive(activeCustomOverlaySlot, 'custom');
|
|
const custom1OverlayActive = isCustomOverlaySlotActive(activeCustomOverlaySlot, 'custom1');
|
|
|
|
const syncCustomOverlaysToManager = useCallback((mgr: ChartManager) => {
|
|
syncChartManagerCustomOverlaysByActiveSlot(
|
|
mgr,
|
|
activeCustomOverlaySlot,
|
|
customOverlaySelection,
|
|
custom1OverlaySelection,
|
|
);
|
|
}, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]);
|
|
|
|
const handleCustomOverlayActiveChange = useCallback((active: boolean) => {
|
|
const nextSlot = resolveActiveCustomOverlaySlot('custom', active, activeCustomOverlaySlot);
|
|
setActiveCustomOverlaySlot(nextSlot);
|
|
const mgr = managerRef.current;
|
|
if (mgr) {
|
|
syncChartManagerCustomOverlaysByActiveSlot(
|
|
mgr,
|
|
nextSlot,
|
|
customOverlaySelection,
|
|
custom1OverlaySelection,
|
|
);
|
|
}
|
|
}, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]);
|
|
|
|
const handleCustom1OverlayActiveChange = useCallback((active: boolean) => {
|
|
const nextSlot = resolveActiveCustomOverlaySlot('custom1', active, activeCustomOverlaySlot);
|
|
setActiveCustomOverlaySlot(nextSlot);
|
|
const mgr = managerRef.current;
|
|
if (mgr) {
|
|
syncChartManagerCustomOverlaysByActiveSlot(
|
|
mgr,
|
|
nextSlot,
|
|
customOverlaySelection,
|
|
custom1OverlaySelection,
|
|
);
|
|
}
|
|
}, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]);
|
|
|
|
const handleSaveCustomOverlaySelection = useCallback((
|
|
slot: ChartCustomOverlaySlotId,
|
|
selection: ChartCustomOverlaySelection,
|
|
) => {
|
|
const nextCustomSel = slot === 'custom' ? selection : customOverlaySelection;
|
|
const nextCustom1Sel = slot === 'custom1' ? selection : custom1OverlaySelection;
|
|
if (slot === 'custom') setCustomOverlaySelection(selection);
|
|
else setCustom1OverlaySelection(selection);
|
|
setCustomOverlaySettingsSlot(null);
|
|
const mgr = managerRef.current;
|
|
if (!mgr) return;
|
|
syncChartManagerCustomOverlaysByActiveSlot(
|
|
mgr,
|
|
activeCustomOverlaySlot,
|
|
nextCustomSel,
|
|
nextCustom1Sel,
|
|
);
|
|
}, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]);
|
|
|
|
const handleToggleCandleOverlay = useCallback((key: ChartOverlayToggleKey) => {
|
|
setOverlayVisibility(prev => {
|
|
const next = { ...prev, [key]: !prev[key] };
|
|
const mgr = managerRef.current;
|
|
mgr?.setChartOverlayGroupVisible(key, next[key]);
|
|
if (mgr) {
|
|
for (const info of mgr.getIndicatorPaneInfo()) {
|
|
if (chartOverlayKeyForType(info.type) !== key) continue;
|
|
const reactInd = indicatorsRef.current.find(i => i.id === info.id);
|
|
mgr.applyIndicatorStyle(reactInd
|
|
? { ...info.config, ...reactInd, id: info.id, type: info.type }
|
|
: info.config);
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const handleDuplicateIndicator = useCallback((sourceId: string) => {
|
|
setIndicators(prev => duplicateIndicatorInList(sourceId, prev, newIndId) ?? prev);
|
|
}, []);
|
|
|
|
const handleRemoveByIdSlot = useCallback((id: string) => {
|
|
setIndicators(prev => removeIndicatorFromList(prev, id));
|
|
setFocusedIndicatorId(prev => prev === id ? null : prev);
|
|
setCtxToolbar(null);
|
|
setSettingsModalId(null);
|
|
}, []);
|
|
|
|
/** 단독 전체화면에서 보여줄 지표 목록 (overlay + 선택된 1개) + 타임프레임 필터 */
|
|
const effectiveIndicators = useMemo(() => {
|
|
// 타임프레임 필터: timeframeVisibility[currentTf] === false 이면 제외
|
|
const tfFiltered = indicators.filter(ind =>
|
|
ind.timeframeVisibility?.[timeframe] !== false
|
|
);
|
|
if (!focusedIndicatorId) return tfFiltered;
|
|
return tfFiltered.filter(ind => {
|
|
const def = getIndicatorDef(ind.type);
|
|
return def?.overlay || def?.returnsMarkers || ind.id === focusedIndicatorId;
|
|
});
|
|
}, [focusedIndicatorId, indicators, timeframe]);
|
|
|
|
// ── 심볼 선택 ─────────────────────────────────────────────────────────
|
|
const handleSymbolSelect = useCallback((s: string) => {
|
|
if (isUpbitMarket(s)) invalidateMarketCache(s);
|
|
pendingSyncVisibleRef.current = null;
|
|
pendingSyncLogicalRef.current = null;
|
|
skipSyncApplyRef.current = true;
|
|
setSymbol(s);
|
|
setLegend(null);
|
|
setShowMarket(false);
|
|
setSlotAnchorRect(null);
|
|
}, []);
|
|
|
|
const openMarketSearch = useCallback((e?: React.MouseEvent) => {
|
|
e?.stopPropagation();
|
|
onActivate();
|
|
if (!showMarket) {
|
|
const anchorEl = compactMode
|
|
? compactNameRef.current
|
|
: (slotContainerRef.current?.closest('.chart-slot') as HTMLElement | null);
|
|
setSlotAnchorRect(anchorEl?.getBoundingClientRect() ?? null);
|
|
} else {
|
|
setSlotAnchorRect(null);
|
|
}
|
|
setShowMarket(v => !v);
|
|
}, [compactMode, onActivate, showMarket]);
|
|
|
|
const displayKoName = compactKoName(symbol);
|
|
const trendUp = compactMode && liveQuote.price != null && liveQuote.isUp;
|
|
const trendDown = compactMode && liveQuote.price != null && !liveQuote.isUp;
|
|
|
|
return (
|
|
<div
|
|
className={[
|
|
'chart-slot',
|
|
active ? 'active' : '',
|
|
compactMode ? 'chart-slot--compact' : '',
|
|
highlightReceiving ? 'chart-slot--receiving' : '',
|
|
trendUp ? 'chart-slot--trend-up' : '',
|
|
trendDown ? 'chart-slot--trend-down' : '',
|
|
].filter(Boolean).join(' ')}
|
|
onClick={onActivate}
|
|
>
|
|
{/* ── 슬롯 헤더 ──────────────────────────────────────────────────────── */}
|
|
<div
|
|
className={`slot-header${compactMode ? ' slot-header--compact' : ''}`}
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
{compactMode ? (
|
|
<div className="slot-compact-row">
|
|
<button
|
|
type="button"
|
|
ref={compactNameRef}
|
|
className={`slot-compact-name${showMarket ? ' open' : ''}`}
|
|
title={`${displayKoName} — 종목 변경`}
|
|
aria-expanded={showMarket}
|
|
aria-haspopup="listbox"
|
|
onClick={openMarketSearch}
|
|
>
|
|
<svg width="11" height="11" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6" style={{ flexShrink: 0 }} aria-hidden>
|
|
<circle cx="6" cy="6" r="4"/><line x1="9.5" y1="9.5" x2="13" y2="13"/>
|
|
</svg>
|
|
<span className="slot-compact-name-text">{displayKoName}</span>
|
|
</button>
|
|
|
|
{liveQuote.price != null && (
|
|
<div className="slot-compact-quote" aria-label="현재가">
|
|
<span
|
|
className="slot-compact-price"
|
|
style={{ color: liveQuote.isUp ? 'var(--up)' : 'var(--down)' }}
|
|
>
|
|
{formatChartLivePrice(liveQuote.price).replace(' KRW', '')}
|
|
</span>
|
|
{liveQuote.changePct != null && (
|
|
<span
|
|
className="slot-compact-change"
|
|
style={{ color: liveQuote.isUp ? 'var(--up)' : 'var(--down)' }}
|
|
>
|
|
{formatChartLiveChangePct(liveQuote.changePct)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<label className="slot-compact-tf" onClick={e => e.stopPropagation()}>
|
|
<span className="slot-compact-tf-label">시간봉</span>
|
|
<select
|
|
className="slot-compact-tf-select"
|
|
value={timeframe}
|
|
aria-label="시간봉 선택"
|
|
onChange={e => setTimeframe(e.target.value as Timeframe)}
|
|
>
|
|
{TF_OPTIONS.map(tf => (
|
|
<option key={tf} value={tf}>{tf}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
<div className="slot-compact-status">
|
|
{isLoading && <span className="slot-loading">로딩…</span>}
|
|
{useUpbit && (
|
|
<VirtualLiveBadge status={wsToLiveStatus(wsStatus)} receiving={receiving} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<button
|
|
className={`slot-sym-btn${showMarket ? ' open' : ''}`}
|
|
title="종목 변경"
|
|
onClick={openMarketSearch}
|
|
>
|
|
<svg width="11" height="11" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6" style={{ flexShrink: 0 }}>
|
|
<circle cx="6" cy="6" r="4"/><line x1="9.5" y1="9.5" x2="13" y2="13"/>
|
|
</svg>
|
|
{getKoreanName(symbol) !== symbol
|
|
? <><span className="slot-sym-kr">{getKoreanName(symbol)}</span><span className="slot-sym-code">{symbol}</span></>
|
|
: <span className="slot-sym-kr">{symbol}</span>
|
|
}
|
|
</button>
|
|
<div className="slot-tf-row">
|
|
{TF_OPTIONS.map(tf => (
|
|
<button
|
|
key={tf}
|
|
className={`slot-tf-btn${tf === timeframe ? ' active' : ''}`}
|
|
onClick={() => setTimeframe(tf)}
|
|
>
|
|
{tf}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{liveQuote.price != null && (
|
|
<span className="slot-price" style={{ color: liveQuote.isUp ? 'var(--up)' : 'var(--down)' }}>
|
|
{formatChartLivePrice(liveQuote.price).replace(' KRW', '')}
|
|
{liveQuote.changePct != null && (
|
|
<span className="slot-change" style={{ color: liveQuote.isUp ? 'var(--up)' : 'var(--down)' }}>
|
|
{formatChartLiveChangePct(liveQuote.changePct)}
|
|
</span>
|
|
)}
|
|
</span>
|
|
)}
|
|
{useUpbit && <WsBadge status={wsStatus} isUpbit={true} />}
|
|
{isLoading && <span className="slot-loading">로딩...</span>}
|
|
</>
|
|
)}
|
|
|
|
{showMarket && ReactDOM.createPortal(
|
|
<MarketSearchPanel
|
|
currentMarket={symbol}
|
|
onSelect={s => { handleSymbolSelect(s); }}
|
|
onClose={() => { setShowMarket(false); setSlotAnchorRect(null); }}
|
|
anchorRect={slotAnchorRect}
|
|
anchorPlacement={compactMode ? 'dropdown' : 'slot'}
|
|
/>,
|
|
document.body,
|
|
)}
|
|
</div>
|
|
|
|
{/* ── TradingChart ─────────────────────────────────────────────────── */}
|
|
<div
|
|
ref={slotContainerRef}
|
|
className={compactMode ? 'vtd-card-chart-canvas slot-chart-wrap slot-chart-wrap--compact' : 'slot-chart-wrap'}
|
|
style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}
|
|
>
|
|
{useUpbit && isLoading && (
|
|
compactMode ? (
|
|
<div className="vtd-card-chart-loading">차트 로딩…</div>
|
|
) : (
|
|
<div className="chart-loading" style={{ position: 'absolute', zIndex: 10 }}>
|
|
<div className="loading-spinner" />
|
|
</div>
|
|
)
|
|
)}
|
|
{isLoadingMore && (
|
|
<div className="chart-history-loading">
|
|
<div className="loading-spinner" style={{ width: 14, height: 14 }} />
|
|
<span>과거 데이터 로딩...</span>
|
|
</div>
|
|
)}
|
|
|
|
<TradingChart
|
|
key={reloadTick}
|
|
chartVisible={chartVisible}
|
|
bars={bars}
|
|
barsMarket={barsMarket}
|
|
market={symbol}
|
|
timeframe={timeframe}
|
|
displayTimezone={displayTimezone}
|
|
chartType={chartType}
|
|
theme={theme}
|
|
mode="chart"
|
|
indicators={effectiveIndicators}
|
|
drawingTool="cursor"
|
|
drawings={drawings}
|
|
logScale={false}
|
|
drawingsLocked={compactMode}
|
|
drawingsVisible={!compactMode}
|
|
showHoverToolbar={!compactMode && chartHoverToolbarVisible}
|
|
volumeVisible={chartVolumeVisible}
|
|
candleAreaPriceLabelsEnabled={chartCandleAreaPriceLabels}
|
|
indicatorAreaPriceLabelsEnabled={chartSeriesPriceLabels}
|
|
paneSeparatorOptions={chartPaneSeparator}
|
|
crosshairInfoVisible={chartCrosshairInfoVisible}
|
|
onCrosshair={data => {
|
|
setLegend(data);
|
|
if (data && onCrosshairTime) onCrosshairTime(data.time ?? 0);
|
|
}}
|
|
onManagerReady={mgr => {
|
|
managerRef.current = mgr;
|
|
const pending = pendingRealtimeBarRef.current;
|
|
if (pending && chartLiveReadyRef.current && barsMarketRef.current === symbolRef.current && bars.length > 0) {
|
|
pendingRealtimeBarRef.current = null;
|
|
const last = bars[bars.length - 1];
|
|
if (last && pending.time === last.time) mgr.updateBar(pending);
|
|
else if (!last || pending.time > last.time) void mgr.appendBar(pending);
|
|
else mgr.updateBar({ ...pending, time: last.time });
|
|
} else {
|
|
pendingRealtimeBarRef.current = null;
|
|
}
|
|
mgr.setCandleAreaPriceLabelsEnabled(chartCandleAreaPriceLabels);
|
|
mgr.setIndicatorAreaPriceLabelsEnabled(chartSeriesPriceLabels);
|
|
mgr.setVolumeVisible(chartVolumeVisible);
|
|
if (chartPaneSeparator) mgr.setPaneSeparatorOptions(chartPaneSeparator);
|
|
mgr.setChartOverlayVisibility(overlayVisibilityRef.current);
|
|
syncCustomOverlaysToManager(mgr);
|
|
// Time sync: 가시 시간 범위 변화 구독
|
|
mgr.subscribeVisibleTimeRange(r => {
|
|
if (!r || isSyncingTimeRef.current) return;
|
|
onVisibleRangeChangeRef.current?.(r);
|
|
});
|
|
// Date range sync: 가시 논리 범위 변화 구독
|
|
// + 왼쪽 스크롤 감지 → 과거 데이터 추가 로드
|
|
mgr.subscribeVisibleLogicalRange(r => {
|
|
if (!r) return;
|
|
if (!isSyncingLogicalRef.current) {
|
|
onLogicalRangeChangeRef.current?.(r);
|
|
}
|
|
// 가시 범위 왼쪽 끝이 LOAD_MORE_TRIGGER 이내이면 추가 로드
|
|
if (r.from < LOAD_MORE_TRIGGER) {
|
|
loadMoreRef.current();
|
|
}
|
|
});
|
|
}}
|
|
onCandlesReady={handleCandlesReady}
|
|
onDataLoaded={() => {
|
|
// reloadAll(setData + setInitialVisibleRange) 완료 후
|
|
// 첫 마운트 시 pending 상태였던 sync range 를 재적용한다
|
|
const mgr = managerRef.current;
|
|
if (!mgr) return;
|
|
mgr.setChartOverlayVisibility(overlayVisibilityRef.current);
|
|
syncCustomOverlaysToManager(mgr);
|
|
if (skipSyncApplyRef.current) {
|
|
skipSyncApplyRef.current = false;
|
|
const fb = mgr.hasIchimoku() ? 28 : 0;
|
|
mgr.setInitialVisibleRange(DISPLAY_COUNT, fb);
|
|
return;
|
|
}
|
|
if (pendingSyncVisibleRef.current) {
|
|
isSyncingTimeRef.current = true;
|
|
mgr.applyVisibleTimeRange(
|
|
pendingSyncVisibleRef.current.from,
|
|
pendingSyncVisibleRef.current.to,
|
|
);
|
|
requestAnimationFrame(() => { isSyncingTimeRef.current = false; });
|
|
pendingSyncVisibleRef.current = null;
|
|
}
|
|
if (pendingSyncLogicalRef.current) {
|
|
isSyncingLogicalRef.current = true;
|
|
mgr.applyVisibleLogicalRange(
|
|
pendingSyncLogicalRef.current.from,
|
|
pendingSyncLogicalRef.current.to,
|
|
);
|
|
requestAnimationFrame(() => { isSyncingLogicalRef.current = false; });
|
|
pendingSyncLogicalRef.current = null;
|
|
}
|
|
}}
|
|
magnifierEnabled={magnifierEnabled}
|
|
onMagnifierClose={onMagnifierClose}
|
|
onAddDrawing={() => {}}
|
|
onSeriesClick={(entryId, point) => {
|
|
if (!entryId) { setCtxToolbar(null); return; }
|
|
setCtxToolbar({ entryId, screenX: point.x, screenY: point.y });
|
|
}}
|
|
onSeriesDoubleClick={entryId => {
|
|
setCtxToolbar(null);
|
|
if (!entryId) return;
|
|
if (entryId === '__main__') setShowMainChartModal(true);
|
|
else setSettingsModalId(entryId);
|
|
}}
|
|
onHoverPaneLegend={() => {}}
|
|
onLeavePaneLegend={() => {}}
|
|
onTradeOrderRequest={req => onTradeOrderRequest?.(req, slotIndex)}
|
|
onReorderIndicators={handleReorderIndicators}
|
|
onMergeIndicators={handleMergeIndicators}
|
|
onSplitIndicatorPane={handleSplitIndicatorPane}
|
|
onExpandIndicator={handleExpandIndicator}
|
|
onRemoveIndicator={handleRemoveByIdSlot}
|
|
onDuplicateIndicator={handleDuplicateIndicator}
|
|
focusedIndicatorId={focusedIndicatorId}
|
|
onRestoreIndicators={handleRestoreIndicators}
|
|
onToggleIndicatorHidden={handleToggleHidden}
|
|
onOpenIndicatorSettings={id => setSettingsModalId(id)}
|
|
onFullView={onFullView ? () => onFullView(symbolRef.current) : undefined}
|
|
candleOverlayToggles={compactMode ? undefined : candleOverlayToggles}
|
|
onToggleCandleOverlay={compactMode ? undefined : handleToggleCandleOverlay}
|
|
customOverlayActive={compactMode ? undefined : customOverlayActive}
|
|
onCustomOverlayActiveChange={compactMode ? undefined : handleCustomOverlayActiveChange}
|
|
onOpenCustomOverlaySettings={compactMode ? undefined : () => setCustomOverlaySettingsSlot('custom')}
|
|
custom1OverlayActive={compactMode ? undefined : custom1OverlayActive}
|
|
onCustom1OverlayActiveChange={compactMode ? undefined : handleCustom1OverlayActiveChange}
|
|
onOpenCustom1OverlaySettings={compactMode ? undefined : () => setCustomOverlaySettingsSlot('custom1')}
|
|
/>
|
|
|
|
{customOverlaySettingsSlot === 'custom' && !compactMode && (
|
|
<ChartCustomOverlaySettingsModal
|
|
title="Custom 표시 설정"
|
|
selection={customOverlaySelection}
|
|
onSave={sel => handleSaveCustomOverlaySelection('custom', sel)}
|
|
onCancel={() => setCustomOverlaySettingsSlot(null)}
|
|
/>
|
|
)}
|
|
{customOverlaySettingsSlot === 'custom1' && !compactMode && (
|
|
<ChartCustomOverlaySettingsModal
|
|
title="Custom1 표시 설정"
|
|
selection={custom1OverlaySelection}
|
|
onSave={sel => handleSaveCustomOverlaySelection('custom1', sel)}
|
|
onCancel={() => setCustomOverlaySettingsSlot(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* 컨텍스트 툴바 */}
|
|
{ctxToolbar && (() => {
|
|
const cfg = ctxToolbar.entryId !== '__main__'
|
|
? indicators.find(i => i.id === ctxToolbar.entryId) ?? null
|
|
: null;
|
|
return (
|
|
<IndicatorContextToolbar
|
|
indicatorId={ctxToolbar.entryId}
|
|
config={cfg}
|
|
chartType={chartType}
|
|
screenX={ctxToolbar.screenX}
|
|
screenY={ctxToolbar.screenY}
|
|
onMouseEnterToolbar={() => {}}
|
|
onMouseLeaveToolbar={() => {}}
|
|
onSettings={() => {
|
|
const id = ctxToolbar.entryId;
|
|
setCtxToolbar(null);
|
|
if (id === '__main__') setShowMainChartModal(true);
|
|
else setSettingsModalId(id);
|
|
}}
|
|
onToggleHidden={() => handleToggleHidden(ctxToolbar.entryId)}
|
|
onRemove={() => handleRemoveById(ctxToolbar.entryId)}
|
|
onClose={() => setCtxToolbar(null)}
|
|
/>
|
|
);
|
|
})()}
|
|
|
|
{/* 메인 차트 설정 모달 */}
|
|
{showMainChartModal && (
|
|
<MainChartSettingsModal
|
|
style={mainChartStyle}
|
|
onSave={style => { setMainChartStyle(style); setShowMainChartModal(false); }}
|
|
onCancel={() => setShowMainChartModal(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* 지표 설정 모달 */}
|
|
{settingsModalId && settingsModalId !== '__main__' && (() => {
|
|
const cfg = indicators.find(i => i.id === settingsModalId);
|
|
if (!cfg) return null;
|
|
const def = getIndicatorDef(cfg.type);
|
|
const mergedCfg = enrichIndicatorConfig({
|
|
...cfg,
|
|
params: getParams(cfg.type, def?.defaultParams),
|
|
hlines: cfg.hlines ?? def?.hlines,
|
|
});
|
|
return (
|
|
<IndicatorSettingsModal
|
|
config={mergedCfg}
|
|
chartMarket={symbol}
|
|
onSave={handleIndicatorSave}
|
|
onCancel={() => setSettingsModalId(null)}
|
|
/>
|
|
);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export default ChartSlot;
|