/** * 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 { 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 { duplicateIndicatorInList, mergeIndicators, reorderIndicatorGroup, removeIndicatorFromList, removeIndicatorTypeFromList, splitIndicatorPane, replaceIndicatorConfigsFromTypes, } from '../utils/indicatorPaneMerge'; import { sortIndicatorsByTypeOrder } from '../utils/indicatorListOrder'; 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 = { 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 ( {label} ); }; 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; /** 거래량 pane on/off */ setVolumeVisible: (visible: boolean) => void; setPaneSeparatorOptions: (opts: ChartPaneSeparatorOptions) => 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; /** 보조지표 우측 가격축 라벨·설명 표시 */ chartSeriesPriceLabels?: boolean; /** 차트 하단 거래량 바 표시 */ chartVolumeVisible?: boolean; chartRealtimeSource?: 'BACKEND_STOMP' | 'UPBIT_DIRECT'; displayTimezone?: string; /** 멀티차트 — 가상투자 카드 차트처럼 컴팩트 카드 UI */ compactMode?: boolean; /** 실시간 수신 시 카드 아웃라인 형광 연두 음영 (설정 연동) */ chartLiveReceiveHighlight?: boolean; /** 실시간 차트 화면 표시 여부 (설정 등 다른 메뉴에서 숨김 시 false) */ chartVisible?: boolean; /** pane 구분선 */ chartPaneSeparator?: ChartPaneSeparatorOptions; } const ChartSlot = forwardRef(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, chartSeriesPriceLabels = true, chartVolumeVisible = true, chartRealtimeSource = 'BACKEND_STOMP', displayTimezone, compactMode = false, chartLiveReceiveHighlight = true, chartVisible = true, chartPaneSeparator, }, ref) { // ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ──────────────────────── const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings(); // ── 자체 상태 ───────────────────────────────────────────────────────── // initial* 값이 있으면 우선 적용 (단일↔다중 모드 전환 시 설정 이전용) const [symbol, setSymbol] = useState(initialSymbol ?? forcedSymbol ?? 'KRW-BTC'); const [timeframe, setTimeframe] = useState(initialTimeframe ?? forcedTf ?? '1D'); const [chartType] = useState('candlestick'); const [indicators, setIndicators] = useState(initialIndicators ?? []); const [drawings] = useState([]); const [legend, setLegend] = useState(null); const [ctxToolbar, setCtxToolbar] = useState<{ entryId: string; screenX: number; screenY: number } | null>(null); const [settingsModalId, setSettingsModalId] = useState(null); const [showMainChartModal, setShowMainChartModal] = useState(false); const [mainChartStyle, setMainChartStyle] = useState(DEFAULT_MAIN_CHART_STYLE); const [showMarket, setShowMarket] = useState(false); // MarketSearchPanel 위치: compact=종목명 버튼, 일반=슬롯 전체 const [slotAnchorRect, setSlotAnchorRect] = useState(null); const managerRef = useRef(null); const compactNameRef = useRef(null); // initialSymbol 도 초기값으로 포함: requestAnimationFrame 이전에 올바른 값 보장 const symbolRef = useRef(initialSymbol ?? forcedSymbol ?? 'KRW-BTC'); const timeframeRef = useRef(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(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); }, setVolumeVisible: (visible: boolean) => { managerRef.current?.setVolumeVisible(visible); }, setPaneSeparatorOptions: (opts: ChartPaneSeparatorOptions) => { managerRef.current?.setPaneSeparatorOptions(opts); }, }), []); // 자석모드 변경 → ChartManager 크로스헤어 모드 즉시 반영 useEffect(() => { managerRef.current?.setCrosshairMagnet(magnetMode !== 'off'); }, [magnetMode]); useEffect(() => { managerRef.current?.setSeriesPriceLabelsEnabled(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 | null>(null); useEffect(() => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(() => { saveSlot(slotIndex, { slotIndex, symbol, timeframe, chartType: 'candlestick', theme, 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, theme, 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(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(() => !isUpbitMarket(symbol) ? generateBars(symbol, timeframe) : [] ); useEffect(() => { if (useUpbit) return; setSimBars(generateBars(symbol, timeframe)); setLegend(null); }, [symbol, timeframe, useUpbit]); const pendingRealtimeBarRef = useRef(null); const barsMarketRef = useRef(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 currentBar = legend ?? lastKnownBar; const isUp = currentBar ? currentBar.close >= currentBar.open : true; const prevClose = bars.length > 1 ? bars[bars.length - 2].close : currentBar?.open ?? 0; const dailyChange = currentBar ? currentBar.close - prevClose : 0; const dailyChangePct = prevClose > 0 ? dailyChange / prevClose * 100 : 0; 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); // 파라미터 변경 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(null); const handleExpandIndicator = useCallback((id: string) => { setFocusedIndicatorId(id); }, []); const handleRestoreIndicators = useCallback(() => { setFocusedIndicatorId(null); }, []); 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 && currentBar != null && dailyChangePct >= 0; const trendDown = compactMode && currentBar != null && dailyChangePct < 0; return (
{/* ── 슬롯 헤더 ──────────────────────────────────────────────────────── */}
e.stopPropagation()} > {compactMode ? (
{currentBar && (
{formatPrice(currentBar.close)} {isUp ? '+' : '-'}{Math.abs(dailyChangePct).toFixed(2)}%
)}
{isLoading && 로딩…} {useUpbit && ( )}
) : ( <>
{TF_OPTIONS.map(tf => ( ))}
{currentBar && ( {formatPrice(currentBar.close)} {isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}% )} {useUpbit && } {isLoading && 로딩...} )} {showMarket && ReactDOM.createPortal( { handleSymbolSelect(s); }} onClose={() => { setShowMarket(false); setSlotAnchorRect(null); }} anchorRect={slotAnchorRect} anchorPlacement={compactMode ? 'dropdown' : 'slot'} />, document.body, )}
{/* ── TradingChart ─────────────────────────────────────────────────── */}
{useUpbit && isLoading && ( compactMode ? (
차트 로딩…
) : (
) )} {isLoadingMore && (
과거 데이터 로딩...
)} { 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.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels); mgr.setVolumeVisible(chartVolumeVisible); if (chartPaneSeparator) mgr.setPaneSeparatorOptions(chartPaneSeparator); // 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; 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} onFullView={onFullView ? () => onFullView(symbolRef.current) : undefined} /> {/* 컨텍스트 툴바 */} {ctxToolbar && (() => { const cfg = ctxToolbar.entryId !== '__main__' ? indicators.find(i => i.id === ctxToolbar.entryId) ?? null : null; return ( {}} 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 && ( { 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 ( setSettingsModalId(null)} /> ); })()}
); }); export default ChartSlot;