2068 lines
88 KiB
TypeScript
2068 lines
88 KiB
TypeScript
import { useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo } from 'react';
|
|
import DrawingToolbar from '../components/DrawingToolbar';
|
|
import BottomBar from '../components/BottomBar';
|
|
import TradingChart from '../components/TradingChart';
|
|
import StatsPanel from '../components/StatsPanel';
|
|
import WatchList from '../components/WatchList';
|
|
import AlertManager from '../components/AlertManager';
|
|
import ObjectTree from '../components/ObjectTree';
|
|
import IndicatorContextToolbar from '../components/IndicatorContextToolbar';
|
|
import IndicatorSettingsModal from '../components/IndicatorSettingsModal';
|
|
import IndicatorBulkSettingsModal from '../components/IndicatorBulkSettingsModal';
|
|
import MainChartSettingsModal from '../components/MainChartSettingsModal';
|
|
import DrawingContextToolbar from '../components/DrawingContextToolbar';
|
|
import DrawingSettingsModal from '../components/DrawingSettingsModal';
|
|
import FibTZSettingsModal from '../components/FibTZSettingsModal';
|
|
import ChartSlot, { type ChartSlotHandle } from '../components/ChartSlot';
|
|
import { LAYOUTS, DEFAULT_SYNC, type LayoutDef, type SyncOptions } from '../utils/layoutTypes';
|
|
import { generateBars, SYMBOLS } from '../utils/dataGenerator';
|
|
import {
|
|
normalizeChartTimeFormat,
|
|
setChartTimeFormat,
|
|
useChartTimeFormat,
|
|
} from '../utils/chartTimeFormat';
|
|
import {
|
|
setTradeAlertTimeFormat,
|
|
useTradeAlertTimeFormat,
|
|
} from '../utils/tradeAlertTimeFormat';
|
|
import { normalizeTimezone, setDisplayTimezone, useDisplayTimezone } from '../utils/timezone';
|
|
import { calcStats, type PriceStats } from '../utils/calculations';
|
|
import { saveState, DEFAULT_MAIN_CHART_STYLE } from '../utils/storage';
|
|
import { useWorkspacePersist } from '../hooks/useWorkspacePersist';
|
|
import type { WatchlistItem } from '../utils/backendApi';
|
|
import {
|
|
addWatchlistItem, removeWatchlistItem,
|
|
loadWatchlist,
|
|
loadPaperSummary,
|
|
} from '../utils/backendApi';
|
|
import { enrichIndicatorConfig, getIndicatorDef } from '../utils/indicatorRegistry';
|
|
import { initializeIndicatorConfigForEditor } from '../utils/indicatorSettingsEditor';
|
|
import {
|
|
buildMainIndicatorConfig,
|
|
chartIndicatorsNeedDefaultsRefresh,
|
|
indicatorSettingsTemplateId,
|
|
indicatorTypeFromSettingsId,
|
|
isIndicatorSettingsTemplateId,
|
|
mergeGlobalDefaultsOntoChartIndicators,
|
|
} from '../utils/indicatorMainConfig';
|
|
import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from '../utils/smaConfig';
|
|
import { normalizeIchimokuConfig } from '../utils/ichimokuConfig';
|
|
import {
|
|
type ChartOverlayToggleKey,
|
|
type ChartOverlayVisibility,
|
|
chartOverlayKeyForType,
|
|
DEFAULT_CHART_OVERLAY_VISIBILITY,
|
|
listChartOverlayToggleItems,
|
|
} from '../utils/chartOverlayVisibility';
|
|
import {
|
|
loadChartPriceExtremeLabelsVisible,
|
|
saveChartPriceExtremeLabelsVisible,
|
|
} from '../utils/chartPriceExtremeLabelsPref';
|
|
import {
|
|
type ChartCustomOverlaySelection,
|
|
type ChartCustomOverlaySlotId,
|
|
createDefaultChartCustomOverlaySelection,
|
|
isCustomOverlaySlotActive,
|
|
resolveActiveCustomOverlaySlot,
|
|
syncChartManagerCustomOverlaysByActiveSlot,
|
|
} from '../utils/chartCustomOverlay';
|
|
import { isUpbitMarket, UPBIT_MARKETS } from '../utils/upbitApi';
|
|
import { getFavorites, saveFavorites, initFavoritesFromDb, FAVORITES_CHANGED_EVENT } from '../utils/marketStorage';
|
|
import { useChartRealtimeData } from '../hooks/useChartRealtimeData';
|
|
import type { OHLCVBar, ChartType, Theme, Timeframe, IndicatorConfig, LegendData, Drawing, ChartMode, MainChartStyle, TradeOrderFillRequest } from '../types';
|
|
import type { ChartManager } from '../utils/ChartManager';
|
|
import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader';
|
|
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
|
|
import {
|
|
consumeSkipNextChartDefaultsSync,
|
|
markChartIndicatorSaveCommitted,
|
|
} from '../utils/indicatorChartDefaultsSync';
|
|
import {
|
|
duplicateIndicatorInList,
|
|
mergeIndicators,
|
|
reorderIndicatorGroup,
|
|
removeIndicatorFromList,
|
|
removeIndicatorTypeFromList,
|
|
splitIndicatorPane,
|
|
replaceIndicatorConfigsFromTypes,
|
|
} from '../utils/indicatorPaneMerge';
|
|
import {
|
|
sortIndicatorsByTypeOrder,
|
|
} from '../utils/indicatorListOrder';
|
|
import { getAppSettingsCache } from '../hooks/useAppSettings';
|
|
import type { AppSettingsDto } from '../utils/backendApi';
|
|
import MarketPanel from '../components/MarketPanel';
|
|
import { type MenuPage } from '../components/TopMenuBar';
|
|
import BacktestPanel from '../components/BacktestPanel';
|
|
import { useBacktest } from '../hooks/useBacktest';
|
|
import LiveStrategyPanel from '../components/LiveStrategyPanel';
|
|
import type { LiveMarker } from '../hooks/useLiveStrategyMarkers';
|
|
import type { LiveSignalNotifierHandle } from '../components/LiveSignalNotifier';
|
|
import BacktestSettingsModal from '../components/BacktestSettingsModal';
|
|
import BacktestAnalysisReportModal from '../components/backtest/BacktestAnalysisReportModal';
|
|
import { buildChartBacktestReportModel } from '../utils/backtestReportModel';
|
|
import { fmtListTimestamp } from '../utils/backtestUiUtils';
|
|
import ChartLegendBar from '../components/ChartLegendBar';
|
|
import { resolveChartLiveQuote } from '../utils/chartLiveQuote';
|
|
import RightSidePanel from '../components/RightSidePanel';
|
|
import {
|
|
type BacktestSettingsDto,
|
|
DEFAULT_BACKTEST_SETTINGS,
|
|
loadBacktestSettings,
|
|
loadStrategies as loadStrategiesForLive,
|
|
loadLiveStrategySettings,
|
|
loadActiveLiveStrategySettings,
|
|
expandLiveStrategySubscriptions,
|
|
type LiveStrategySettingsDto,
|
|
type BacktestSignal,
|
|
} from '../utils/backendApi';
|
|
import { useVirtualLiveStrategy } from '../hooks/useVirtualLiveStrategy';
|
|
import type { ChartRealtimeSource } from '../hooks/useChartRealtimeData';
|
|
import { VIRTUAL_SESSION_CHANGED_EVENT } from '../utils/virtualTradingStorage';
|
|
import { notifyPaperTradesChanged } from '../utils/paperTradeEvents';
|
|
import { useIsMobile, useIsCompactDevice } from '../hooks/useMediaQuery';
|
|
import { useMarketTicker, type TickerData } from '../hooks/useMarketTicker';
|
|
import { isTickerFeedPage } from '../utils/tickerFeedPages';
|
|
import ChartCustomOverlaySettingsModal from '../components/ChartCustomOverlaySettingsModal';
|
|
import MobileChartDock from '../components/MobileChartDock';
|
|
import { ChartToolbarNotify } from '../components/NotifyUiBindings';
|
|
import { loadState } from '../utils/storage';
|
|
import { resolveChartLegendOptions } from '../types/chartLegend';
|
|
import { resolveChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
|
|
import type { ChartWorkspaceBridge } from './chartWorkspaceBridge';
|
|
import type { ChartWorkspaceSharedState } from './ChartWorkspaceContext';
|
|
import type { ChartWorkspaceViewProps } from './ChartWorkspaceView';
|
|
|
|
let _indCounter = 0;
|
|
function newIndId() { return `ind_${++_indCounter}_${Date.now()}`; }
|
|
|
|
/** 모든 .ws-dot 요소에 형광 glow 플래시 (React state 없이 직접 DOM 조작) */
|
|
let _wsFlashTimer: ReturnType<typeof setTimeout> | null = null;
|
|
function flashWsDot() {
|
|
document.querySelectorAll('.ws-dot').forEach(el => el.classList.add('receiving'));
|
|
if (_wsFlashTimer) clearTimeout(_wsFlashTimer);
|
|
_wsFlashTimer = setTimeout(() => {
|
|
document.querySelectorAll('.ws-dot').forEach(el => el.classList.remove('receiving'));
|
|
_wsFlashTimer = null;
|
|
}, 600);
|
|
}
|
|
|
|
export interface UseChartWorkspaceParams {
|
|
sessionKey: number;
|
|
menuPage: MenuPage;
|
|
formalLogin: boolean;
|
|
requireFormalLogin: () => void;
|
|
theme: Theme;
|
|
onThemeToggle: () => void;
|
|
appDefaults: import('../utils/backendApi').AppSettingsDto;
|
|
appSettingsLoaded: boolean;
|
|
saveAppDef: (patch: Partial<AppSettingsDto>) => void;
|
|
getParams: ReturnType<typeof import('../hooks/useIndicatorSettings').useIndicatorSettings>['getParams'];
|
|
saveParams: ReturnType<typeof import('../hooks/useIndicatorSettings').useIndicatorSettings>['saveParams'];
|
|
getVisualConfig: ReturnType<typeof import('../hooks/useIndicatorSettings').useIndicatorSettings>['getVisualConfig'];
|
|
saveVisual: ReturnType<typeof import('../hooks/useIndicatorSettings').useIndicatorSettings>['saveVisual'];
|
|
saveAllIndicatorDefaults: ReturnType<typeof import('../hooks/useIndicatorSettings').useIndicatorSettings>['saveAllIndicatorDefaults'];
|
|
indicatorSettingsRevision: number;
|
|
onOpenNotificationsPage: () => void;
|
|
}
|
|
|
|
export interface SettingsPageChartBindings {
|
|
magnetMode: 'off' | 'weak' | 'strong';
|
|
onMagnetMode: (m: 'off' | 'weak' | 'strong') => void;
|
|
liveStrategies: { id: number; name: string }[];
|
|
onClearLiveMarkers: () => void;
|
|
liveSettings: LiveStrategySettingsDto;
|
|
onLiveSettingsChange: (saved: LiveStrategySettingsDto) => void;
|
|
watchlistCount: number;
|
|
monitoredMarkets: string[];
|
|
virtualDriven: boolean;
|
|
chartIndicators: IndicatorConfig[];
|
|
onAddIndicatorToChart: (type: string, fromConfig?: IndicatorConfig) => void;
|
|
onRemoveIndicatorFromChart: (type: string) => void;
|
|
onIndicatorListOrderChange: (orderedTypes: string[]) => void;
|
|
chartCandleAreaPriceLabels: boolean;
|
|
onChartCandleAreaPriceLabels: (v: boolean) => void;
|
|
chartSeriesPriceLabels: boolean;
|
|
onChartSeriesPriceLabels: (v: boolean) => void;
|
|
chartVolumeVisible: boolean;
|
|
onChartVolumeVisible: (v: boolean) => void;
|
|
chartLiveReceiveHighlight: boolean;
|
|
onChartLiveReceiveHighlight: (v: boolean) => void;
|
|
chartLegendOptions: import('../types/chartLegend').ChartLegendVisibility | undefined;
|
|
onChartLegendOptionsChange: (patch: Partial<import('../types/chartLegend').ChartLegendVisibility>) => void;
|
|
chartCrosshairInfoVisible: boolean;
|
|
onChartCrosshairInfoVisible: (v: boolean) => void;
|
|
chartHoverToolbarVisible: boolean;
|
|
onChartHoverToolbarVisible: (v: boolean) => void;
|
|
chartPaneSeparator: import('../types/chartPaneSeparator').ChartPaneSeparatorOptions | undefined;
|
|
onChartPaneSeparatorChange: (opts: import('../types/chartPaneSeparator').ChartPaneSeparatorOptions) => void;
|
|
onPaperAccountReset: () => Promise<void>;
|
|
onDisplayTimezoneChange: (tz: string) => void;
|
|
onChartTimeFormatChange: (format: string) => void;
|
|
onTradeAlertTimeFormatChange: (format: string) => void;
|
|
displayTimezone: string;
|
|
chartTimeFormat: string;
|
|
tradeAlertTimeFormat: string;
|
|
}
|
|
|
|
export function useChartWorkspace({
|
|
sessionKey,
|
|
menuPage,
|
|
formalLogin,
|
|
requireFormalLogin,
|
|
theme,
|
|
onThemeToggle,
|
|
appDefaults,
|
|
appSettingsLoaded,
|
|
saveAppDef,
|
|
getParams,
|
|
saveParams,
|
|
getVisualConfig,
|
|
saveVisual,
|
|
saveAllIndicatorDefaults: _saveAllIndicatorDefaults,
|
|
indicatorSettingsRevision,
|
|
onOpenNotificationsPage,
|
|
}: UseChartWorkspaceParams) {
|
|
void _saveAllIndicatorDefaults;
|
|
const initial = loadState();
|
|
const chartVisible = menuPage === 'chart';
|
|
const chartTimeFormat = useChartTimeFormat();
|
|
const tradeAlertTimeFormat = useTradeAlertTimeFormat();
|
|
const displayTimezone = useDisplayTimezone();
|
|
const [symbol, setSymbol] = useState(initial.symbol);
|
|
const [timeframe, setTimeframe] = useState<Timeframe>(initial.timeframe);
|
|
const [chartType, setChartType] = useState<ChartType>(initial.chartType);
|
|
const [mode, setMode] = useState<ChartMode>('chart');
|
|
const [logScale, setLogScale] = useState(false);
|
|
const [indicators, setIndicators] = useState<IndicatorConfig[]>(initial.indicators ?? []);
|
|
const indicatorsRef = useRef(indicators);
|
|
useEffect(() => { indicatorsRef.current = indicators; }, [indicators]);
|
|
const [drawingTool, setDrawingTool] = useState('cursor');
|
|
const [drawings, setDrawings] = useState<Drawing[]>([]);
|
|
const [drawingsLocked, setDrawingsLocked] = useState(false);
|
|
const [drawingsVisible, setDrawingsVisible] = useState(true);
|
|
const [indicatorsVisible, setIndicatorsVisible] = useState(true);
|
|
const [magnetMode, setMagnetMode] = useState<'off'|'weak'|'strong'>('off');
|
|
const [snapToIndicator, setSnapToIndicator] = useState(false);
|
|
const [keepLockedOnDelete,setKeepLockedOnDelete]= useState(false);
|
|
const [legend, setLegend] = useState<LegendData | null>(null);
|
|
const [showStats, setShowStats] = useState(false);
|
|
const [showWatch, setShowWatch] = useState(false);
|
|
const [showAlert, setShowAlert] = useState(false);
|
|
const [showObjectTree, setShowObjectTree] = useState(false);
|
|
const [percentScale, setPercentScale] = useState(false);
|
|
const [showGrid, setShowGrid] = useState(true);
|
|
const [magnifierActive, setMagnifierActive] = useState(false);
|
|
const [showMarketPanel, setShowMarketPanel] = useState(false);
|
|
/** 종목검색 패널 열림 — 전 종목 ticker loadFull 트리거 */
|
|
const [marketSearchOpen, setMarketSearchOpen] = useState(false);
|
|
const [mobileRightOpen, setMobileRightOpen] = useState(false);
|
|
const [mobileRightTab, setMobileRightTab] = useState<'trade' | 'orderbook'>('trade');
|
|
const isMobile = useIsMobile();
|
|
const isCompactDevice = useIsCompactDevice();
|
|
const tickerFeedActive = isTickerFeedPage(menuPage);
|
|
|
|
// ── 레이아웃 상태 ─────────────────────────────────────────────────────
|
|
const [layoutDef, setLayoutDef] = useState<LayoutDef>(() => LAYOUTS[0]);
|
|
const [syncOptions, setSyncOptions] = useState<SyncOptions>(DEFAULT_SYNC);
|
|
const [activeSlot, setActiveSlot] = useState(0);
|
|
// 멀티 레이아웃에서 현재 활성 슬롯의 지표 목록 (IndicatorModal 표시용)
|
|
const [activeSlotIndicators, setActiveSlotIndicators] = useState<IndicatorConfig[]>([]);
|
|
/**
|
|
* 단일→다중 전환 시 슬롯 0 의 마운트 초기값으로 전달할 설정.
|
|
* React 18 배치 업데이트 덕분에 setLayoutDef 와 동일 렌더에 반영되어
|
|
* ChartSlot 이 처음 마운트될 때 이 값이 useState 초기값으로 사용된다.
|
|
*/
|
|
const [slot0InitProps, setSlot0InitProps] = useState<{
|
|
symbol: string;
|
|
timeframe: Timeframe;
|
|
indicators: IndicatorConfig[];
|
|
} | null>(null);
|
|
/** 멀티슬롯용: 슬롯 1+ 초기 props (슬롯 인덱스 → 초기값 맵) */
|
|
const [slotInitProps, setSlotInitProps] = useState<Record<number, {
|
|
symbol?: string;
|
|
timeframe?: Timeframe;
|
|
indicators?: IndicatorConfig[];
|
|
mainChartStyle?: MainChartStyle;
|
|
}>>({});
|
|
const [syncTime, setSyncTime] = useState<number>(0);
|
|
const [syncVisibleRange, setSyncVisibleRange] = useState<{ from: number; to: number } | null>(null);
|
|
const [syncLogicalRange, setSyncLogicalRange] = useState<{ from: number; to: number } | null>(null);
|
|
// 멀티 레이아웃에서 활성 슬롯의 타임프레임을 BottomBar/툴바에 반영
|
|
const [activeSlotTf, setActiveSlotTf] = useState<Timeframe>(timeframe);
|
|
const [activeSlotSymbol, setActiveSlotSymbol] = useState<string>(initial.symbol);
|
|
|
|
const handleTimezoneChange = useCallback((tz: string) => {
|
|
const next = normalizeTimezone(tz);
|
|
setDisplayTimezone(next);
|
|
saveAppDef({ displayTimezone: next });
|
|
const tf = layoutDef.count > 1 ? activeSlotTf : timeframe;
|
|
managerRef.current?.setDisplayTimezone(next, tf, chartTimeFormat);
|
|
}, [saveAppDef, layoutDef.count, activeSlotTf, timeframe, chartTimeFormat]);
|
|
|
|
const applyChartTimeFormatToManagers = useCallback((next: string) => {
|
|
const tf = layoutDef.count > 1 ? activeSlotTf : timeframe;
|
|
managerRef.current?.setChartTimeFormat(next);
|
|
slotRefs.current.forEach(slot => slot?.setChartTimeFormat?.(next, tf));
|
|
}, [layoutDef.count, activeSlotTf, timeframe]);
|
|
|
|
const handleChartTimeFormatChange = useCallback((format: string) => {
|
|
const next = normalizeChartTimeFormat(format);
|
|
setChartTimeFormat(next);
|
|
saveAppDef({ chartTimeFormat: next });
|
|
applyChartTimeFormatToManagers(next);
|
|
}, [saveAppDef, applyChartTimeFormatToManagers]);
|
|
|
|
const handleTradeAlertTimeFormatChange = useCallback((format: string) => {
|
|
const next = normalizeChartTimeFormat(format);
|
|
setTradeAlertTimeFormat(next);
|
|
saveAppDef({ tradeAlertTimeFormat: next });
|
|
}, [saveAppDef]);
|
|
|
|
const chartSymbol = layoutDef.count > 1 ? activeSlotSymbol : symbol;
|
|
const chartScreenActive = chartVisible;
|
|
const orderbookEnabled = chartScreenActive && isUpbitMarket(chartSymbol);
|
|
const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(chartSymbol, orderbookEnabled);
|
|
|
|
const [tradeFill, setTradeFill] = useState<TradeOrderFillRequest | null>(null);
|
|
|
|
const applyTradeFill = useCallback((
|
|
req: { market: string; price: number; side: 'buy' | 'sell' },
|
|
slotIndex?: number,
|
|
) => {
|
|
setTradeFill(prev => ({
|
|
market: req.market,
|
|
price: req.price,
|
|
side: req.side,
|
|
seq: (prev?.seq ?? 0) + 1,
|
|
}));
|
|
if (layoutDef.count > 1) {
|
|
const idx = slotIndex ?? activeSlot;
|
|
setActiveSlot(idx);
|
|
slotRefs.current[idx]?.setSymbol(req.market);
|
|
setActiveSlotSymbol(req.market);
|
|
} else if (req.market !== symbol) {
|
|
setSymbol(req.market);
|
|
setLegend(null);
|
|
}
|
|
}, [layoutDef.count, activeSlot, symbol]);
|
|
|
|
const handleOrderbookPick = useCallback((price: number, rowType: 'ask' | 'bid') => {
|
|
applyTradeFill({
|
|
market: chartSymbol,
|
|
price,
|
|
// 매수 호가(bid) 클릭 → 매수 탭, 매도 호가(ask) 클릭 → 매도 탭
|
|
side: rowType === 'bid' ? 'buy' : 'sell',
|
|
});
|
|
}, [applyTradeFill, chartSymbol]);
|
|
|
|
const handleTradeMarketSelect = useCallback((s: string) => {
|
|
if (layoutDef.count > 1) {
|
|
slotRefs.current[activeSlot]?.setSymbol(s);
|
|
setActiveSlotSymbol(s);
|
|
} else {
|
|
setSymbol(s);
|
|
setLegend(null);
|
|
}
|
|
}, [layoutDef.count, activeSlot]);
|
|
|
|
// 활성 슬롯 변경 시 해당 슬롯의 타임프레임/심볼/지표를 읽어 표시 업데이트
|
|
const handleSetActiveSlot = useCallback((idx: number) => {
|
|
setActiveSlot(idx);
|
|
// symbolRef는 initialSymbol로 초기화되어 있으므로 즉시 읽어도 정확한 값 반환
|
|
const slotImmediate = slotRefs.current[idx];
|
|
if (slotImmediate) {
|
|
const sym = slotImmediate.getSymbol();
|
|
if (sym) setActiveSlotSymbol(sym);
|
|
const tf = slotImmediate.getTimeframe();
|
|
if (tf) setActiveSlotTf(tf);
|
|
setActiveSlotIndicators(slotImmediate.getIndicators());
|
|
} else {
|
|
// 슬롯이 아직 마운트되지 않은 경우 rAF로 재시도
|
|
requestAnimationFrame(() => {
|
|
const slot = slotRefs.current[idx];
|
|
if (!slot) return;
|
|
const tf = slot.getTimeframe();
|
|
if (tf) setActiveSlotTf(tf);
|
|
const sym = slot.getSymbol();
|
|
if (sym) setActiveSlotSymbol(sym);
|
|
setActiveSlotIndicators(slot.getIndicators());
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
// 멀티 모드에서 활성 슬롯 지표가 바뀌면 상태 갱신 (IndicatorModal 반영용)
|
|
const handleActiveSlotIndicatorsChange = useCallback((inds: IndicatorConfig[]) => {
|
|
setActiveSlotIndicators(inds);
|
|
}, []);
|
|
|
|
// 멀티 모드에서 활성 슬롯 심볼이 바뀌면 상태 갱신 (호가창 심볼 동기화)
|
|
const handleActiveSlotSymbolChange = useCallback((sym: string) => {
|
|
setActiveSlotSymbol(sym);
|
|
}, []);
|
|
|
|
// Toolbar 에 전달할 활성 지표 목록:
|
|
// 멀티 레이아웃이면 활성 슬롯 지표, 싱글이면 전역 indicators
|
|
const toolbarActiveIndicators = layoutDef.count > 1 ? activeSlotIndicators : indicators;
|
|
|
|
/**
|
|
* 레이아웃 전환 핸들러
|
|
* ─ 단일 → 다중: 현재 싱글 차트의 심볼/타임프레임/지표를 슬롯 0 의 초기값으로 기록
|
|
* ─ 다중 → 단일: 활성 슬롯의 심볼/타임프레임/지표를 싱글 차트 상태에 즉시 복원
|
|
* (ChartSlot 이 unmount 되기 전에 ref 에서 읽어야 함)
|
|
*/
|
|
const handleLayoutSelect = useCallback((def: LayoutDef) => {
|
|
if (def.count > 1 && layoutDef.count === 1) {
|
|
// 단일 → 다중: 싱글 차트 설정을 슬롯 0 초기값으로 저장
|
|
// setLayoutDef 와 같은 렌더에 배치되어 ChartSlot 마운트 시 반영됨
|
|
setSlot0InitProps({
|
|
symbol,
|
|
timeframe,
|
|
indicators: indicators.map(i => ({ ...i })),
|
|
});
|
|
setActiveSlot(0);
|
|
setActiveSlotIndicators(indicators.map(i => ({ ...i })));
|
|
} else if (def.count === 1 && layoutDef.count > 1) {
|
|
// 다중 → 단일: 활성 슬롯 설정 읽기 (slot ref 는 이 시점에서만 유효)
|
|
const slot = slotRefs.current[activeSlot];
|
|
if (slot) {
|
|
const slotSymbol = slot.getSymbol();
|
|
const slotTf = slot.getTimeframe();
|
|
const slotInds = slot.getIndicators().map(i => ({ ...i }));
|
|
setSymbol(slotSymbol);
|
|
setTimeframe(slotTf);
|
|
setIndicators(slotInds);
|
|
setLegend(null);
|
|
}
|
|
// 다음번 다중 전환 시 초기값이 남아있지 않도록 초기화
|
|
setSlot0InitProps(null);
|
|
}
|
|
setLayoutDef(def);
|
|
// 레이아웃 변경 시 DB 에도 저장
|
|
saveAppDef({ defaultLayoutId: def.id });
|
|
}, [layoutDef.count, activeSlot, symbol, timeframe, indicators, saveAppDef]);
|
|
|
|
const appLayoutHydratedRef = useRef(false);
|
|
useEffect(() => {
|
|
if (!appSettingsLoaded || appLayoutHydratedRef.current) return;
|
|
appLayoutHydratedRef.current = true;
|
|
if (appDefaults.defaultLayoutId) {
|
|
const def = LAYOUTS.find(l => l.id === appDefaults.defaultLayoutId);
|
|
if (def) setLayoutDef(def);
|
|
}
|
|
if (appDefaults.syncOptions) {
|
|
setSyncOptions(appDefaults.syncOptions as unknown as SyncOptions);
|
|
}
|
|
}, [appSettingsLoaded, appDefaults.defaultLayoutId, appDefaults.syncOptions]);
|
|
|
|
// 다중 → 싱글 전환 시 activeSlotIndicators 초기화
|
|
useEffect(() => {
|
|
if (layoutDef.count <= 1) setActiveSlotIndicators([]);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [layoutDef.count]);
|
|
|
|
// ── 시리즈 클릭/호버 컨텍스트 툴바 ────────────────────────────────────
|
|
const [ctxToolbar, setCtxToolbar] = useState<{
|
|
entryId: string;
|
|
screenX: number;
|
|
screenY: number;
|
|
} | null>(null);
|
|
// 호버 hide 지연 타이머 (레이블 → 툴바 이동 시 깜빡임 방지)
|
|
const hoverHideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const clearHoverTimer = useCallback(() => {
|
|
if (hoverHideTimer.current) { clearTimeout(hoverHideTimer.current); hoverHideTimer.current = null; }
|
|
}, []);
|
|
|
|
const handleHoverPaneLegend = useCallback((id: string, sx: number, sy: number) => {
|
|
clearHoverTimer();
|
|
setCtxToolbar({ entryId: id, screenX: sx, screenY: sy });
|
|
}, [clearHoverTimer]);
|
|
|
|
const handleLeavePaneLegend = useCallback(() => {
|
|
clearHoverTimer();
|
|
hoverHideTimer.current = setTimeout(() => {
|
|
setCtxToolbar(null);
|
|
hoverHideTimer.current = null;
|
|
}, 180);
|
|
}, [clearHoverTimer]);
|
|
|
|
// ── 드로잉 컨텍스트 툴바 & 설정 모달 ────────────────────────────────────
|
|
const [selectedDrawingId, setSelectedDrawingId] = useState<string | null>(null);
|
|
const [drawingCtxToolbar, setDrawingCtxToolbar] = useState<{
|
|
id: string; screenX: number; screenY: number;
|
|
} | null>(null);
|
|
const [drawingSettingsId, setDrawingSettingsId] = useState<string | null>(null);
|
|
|
|
const handleDrawingClick = useCallback((id: string | null, sx: number, sy: number) => {
|
|
if (!id) {
|
|
// 빈 공간 클릭 → 선택 해제
|
|
setSelectedDrawingId(null);
|
|
setDrawingCtxToolbar(null);
|
|
return;
|
|
}
|
|
setSelectedDrawingId(id);
|
|
setDrawingCtxToolbar({ id, screenX: sx, screenY: sy });
|
|
setCtxToolbar(null); // 인디케이터 툴바 닫기
|
|
}, []);
|
|
|
|
const handleDrawingDoubleClick = useCallback((id: string, sx: number, sy: number) => {
|
|
setSelectedDrawingId(id);
|
|
setDrawingCtxToolbar(null);
|
|
setDrawingSettingsId(id);
|
|
void sx; void sy;
|
|
}, []);
|
|
|
|
// ── 인디케이터 설정 모달 ───────────────────────────────────────────────
|
|
const [settingsModalId, setSettingsModalId] = useState<string | null>(null);
|
|
const [showBulkIndSettings, setShowBulkIndSettings] = useState(false);
|
|
|
|
// ── 메인 차트 설정 ─────────────────────────────────────────────────────
|
|
const [mainChartStyle, setMainChartStyle] = useState<MainChartStyle>(
|
|
initial.mainChartStyle ?? DEFAULT_MAIN_CHART_STYLE
|
|
);
|
|
const [showMainChartModal, setShowMainChartModal] = useState(false);
|
|
|
|
const handleMainChartSave = useCallback((style: MainChartStyle) => {
|
|
setMainChartStyle(style);
|
|
saveState({ mainChartStyle: style });
|
|
setShowMainChartModal(false);
|
|
// DB 에도 저장 → 다음 로드 시 하드코딩 기본값 대신 사용
|
|
saveAppDef({ mainChartStyle: style as unknown as Record<string, string> });
|
|
}, [saveAppDef]);
|
|
|
|
// ── 드로잉 Undo/Redo 스택 ────────────────────────────────────────────────
|
|
const undoStack = useRef<Drawing[][]>([]);
|
|
const redoStack = useRef<Drawing[][]>([]);
|
|
const [canUndo, setCanUndo] = useState(false);
|
|
const [canRedo, setCanRedo] = useState(false);
|
|
|
|
const pushDrawing = useCallback((newDrawings: Drawing[]) => {
|
|
undoStack.current.push(drawings);
|
|
redoStack.current = [];
|
|
setDrawings(newDrawings);
|
|
setCanUndo(true);
|
|
setCanRedo(false);
|
|
}, [drawings]);
|
|
|
|
const handleUndo = useCallback(() => {
|
|
if (undoStack.current.length === 0) return;
|
|
redoStack.current.push(drawings);
|
|
const prev = undoStack.current.pop()!;
|
|
setDrawings(prev);
|
|
setCanUndo(undoStack.current.length > 0);
|
|
setCanRedo(true);
|
|
}, [drawings]);
|
|
|
|
const handleRedo = useCallback(() => {
|
|
if (redoStack.current.length === 0) return;
|
|
undoStack.current.push(drawings);
|
|
const next = redoStack.current.pop()!;
|
|
setDrawings(next);
|
|
setCanUndo(true);
|
|
setCanRedo(redoStack.current.length > 0);
|
|
}, [drawings]);
|
|
const [stats, setStats] = useState<PriceStats | null>(null);
|
|
const [watchlist, setWatchlist] = useState<string[]>(() => {
|
|
const base = initial.watchlist?.length
|
|
? initial.watchlist
|
|
: [...UPBIT_MARKETS.slice(0, 5), ...SYMBOLS.slice(0, 4)];
|
|
return [...new Set([...base, ...getFavorites()])];
|
|
});
|
|
/** 좌측 패널 관심종목(gc_favorites) — 종목 검색 드롭다운 별표·실시간 전략과 동기화 */
|
|
const [favoritesList, setFavoritesList] = useState<string[]>(getFavorites);
|
|
// DB 로드 완료 여부 (false → DB 로드 전, localStorage 기반 동작)
|
|
const [dbLoaded, setDbLoaded] = useState(false);
|
|
/** DB·앱설정·지표 기본값 반영 완료 — 이후 STOMP 초기 로딩 시작 (이중 페인트 방지) */
|
|
const [chartBootstrapComplete, setChartBootstrapComplete] = useState(false);
|
|
/** 싱글 레이아웃 차트 paint 완료 */
|
|
const [singleChartPaintReady, setSingleChartPaintReady] = useState(false);
|
|
/** 멀티 슬롯별 paint 완료 */
|
|
const slotPaintReadyRef = useRef<Record<number, boolean>>({});
|
|
const [slotPaintEpoch, setSlotPaintEpoch] = useState(0);
|
|
|
|
const priorityMarkets = useMemo(
|
|
() => [symbol, ...watchlist],
|
|
[symbol, watchlist],
|
|
);
|
|
const getPriorityMarkets = useCallback(() => priorityMarkets, [priorityMarkets]);
|
|
const marketTickerOpts = useMemo(() => ({
|
|
enabled: tickerFeedActive,
|
|
lite: isCompactDevice,
|
|
loadFull: tickerFeedActive && (showMarketPanel || marketSearchOpen),
|
|
getPriorityMarkets,
|
|
}), [tickerFeedActive, isCompactDevice, showMarketPanel, marketSearchOpen, getPriorityMarkets]);
|
|
const {
|
|
tickers: marketTickers,
|
|
marketInfos,
|
|
loading: marketLoading,
|
|
usdRate,
|
|
refreshAllTickers,
|
|
} = useMarketTicker(marketTickerOpts);
|
|
const chartTicker = marketTickers.get(chartSymbol);
|
|
const orderbookPrevClose = chartTicker
|
|
? (chartTicker.tradePrice ?? 0) - (chartTicker.changePrice ?? 0)
|
|
: 0;
|
|
|
|
const chartCandleAreaPriceLabels = appDefaults.chartCandleAreaPriceLabels ?? true;
|
|
const chartSeriesPriceLabels = appDefaults.chartSeriesPriceLabels ?? true;
|
|
const chartVolumeVisible = appDefaults.chartVolumeVisible ?? true;
|
|
const chartLiveReceiveHighlight = appDefaults.chartLiveReceiveHighlight ?? true;
|
|
const chartLegendOptions = resolveChartLegendOptions(appDefaults.chartLegendOptions);
|
|
const chartCrosshairInfoVisible = appDefaults.chartCrosshairInfoVisible ?? true;
|
|
const chartHoverToolbarVisible = appDefaults.chartHoverToolbarVisible ?? true;
|
|
const chartPaneSeparator = resolveChartPaneSeparatorOptions(appDefaults.chartPaneSeparator);
|
|
const paperTradingEnabled = appDefaults.paperTradingEnabled ?? true;
|
|
const paperAutoTradeEnabled = appDefaults.paperAutoTradeEnabled ?? false;
|
|
const [paperCashBalance, setPaperCashBalance] = useState(0);
|
|
const [paperCoinQty, setPaperCoinQty] = useState(0);
|
|
const [paperRefreshKey, setPaperRefreshKey] = useState(0);
|
|
|
|
const handlePaperOrderFilled = useCallback(() => {
|
|
setPaperRefreshKey(k => k + 1);
|
|
notifyPaperTradesChanged();
|
|
}, []);
|
|
|
|
const refreshPaperAccount = useCallback(async (forMarket?: string) => {
|
|
const summary = await loadPaperSummary();
|
|
if (!summary) return;
|
|
setPaperCashBalance(summary.cashBalance);
|
|
const m = forMarket ?? symbol;
|
|
const pos = summary.positions?.find(p => p.symbol === m);
|
|
setPaperCoinQty(pos?.quantity ?? 0);
|
|
}, [symbol]);
|
|
|
|
useEffect(() => {
|
|
if (!appSettingsLoaded || !formalLogin) return;
|
|
refreshPaperAccount();
|
|
}, [appSettingsLoaded, formalLogin, refreshPaperAccount]);
|
|
|
|
useEffect(() => {
|
|
if (!appSettingsLoaded || !appDefaults.fcmPushEnabled) return;
|
|
import('../utils/fcmPush').then(({ initFcmPush }) => {
|
|
void initFcmPush((title, body) => {
|
|
console.info('[FCM]', title, body);
|
|
});
|
|
});
|
|
}, [appSettingsLoaded, appDefaults.fcmPushEnabled]);
|
|
|
|
useEffect(() => {
|
|
if (!appSettingsLoaded || !formalLogin || !paperTradingEnabled) return;
|
|
refreshPaperAccount(symbol);
|
|
}, [symbol, appSettingsLoaded, formalLogin, paperTradingEnabled, refreshPaperAccount]);
|
|
|
|
const [alerts, setAlerts] = useState<Array<{ price: number; label: string }>>(
|
|
(initial.alertPrices ?? []).map(p => ({ price: p, label: '' }))
|
|
);
|
|
|
|
const managerRef = useRef<ChartManager | null>(null);
|
|
/** 차트 매니저 준비 전 도착한 STOMP 틱 (준비 후 1회 적용) */
|
|
const pendingRealtimeBarRef = useRef<OHLCVBar | null>(null);
|
|
/** reloadAll setData 완료 전 — 이전 종목 rawBars에 실시간 틱 혼입 방지 */
|
|
const chartLiveReadyRef = useRef(false);
|
|
const useUpbit = isUpbitMarket(symbol);
|
|
|
|
// ── 백테스팅 ───────────────────────────────────────────────────────────────
|
|
const {
|
|
signals: btSignals,
|
|
stats: btStats,
|
|
analysis: btAnalysis,
|
|
resultSymbol: btSymbol,
|
|
resultTimeframe: btTimeframe,
|
|
runSeq: btRunSeq,
|
|
running: btRunning,
|
|
run: btRun,
|
|
clear: btClear,
|
|
invalidateRuns: btInvalidateRuns,
|
|
} = useBacktest();
|
|
const [btSettings, setBtSettings] = useState<BacktestSettingsDto>(DEFAULT_BACKTEST_SETTINGS);
|
|
const [showBtSettings, setShowBtSettings] = useState(false);
|
|
const [showBtResult, setShowBtResult] = useState(false);
|
|
const [btStrategyName, setBtStrategyName] = useState('전략');
|
|
const [btReportCreatedAt, setBtReportCreatedAt] = useState<string | undefined>();
|
|
|
|
/** 현재 차트와 일치하는 백테스팅 결과만 UI·마커에 반영 */
|
|
const btMatchesChart = useMemo(
|
|
() =>
|
|
btSignals.length > 0 &&
|
|
btSymbol != null &&
|
|
btTimeframe != null &&
|
|
btSymbol === symbol &&
|
|
btTimeframe === timeframe,
|
|
[btSignals.length, btSymbol, btTimeframe, symbol, timeframe],
|
|
);
|
|
|
|
const btMarkerPayloadRef = useRef<{
|
|
symbol: string;
|
|
timeframe: string;
|
|
signals: BacktestSignal[];
|
|
showPrice: boolean;
|
|
} | null>(null);
|
|
/** applyBacktestMarkersWhenReady rAF 재시도 시 최신 차트 종목·타임프레임 검증용 */
|
|
const chartContextRef = useRef({ symbol, timeframe });
|
|
chartContextRef.current = { symbol, timeframe };
|
|
const barsMarketRef = useRef<string | null>(null);
|
|
|
|
const syncBacktestMarkersRef = useRef<() => void>(() => {});
|
|
|
|
/** ChartManager 메인 시리즈 준비 후 백테스트 마커 적용 (로딩·remount 직후 대응) */
|
|
const applyBacktestMarkersWhenReady = useCallback((
|
|
payload: NonNullable<typeof btMarkerPayloadRef.current>,
|
|
attempt = 0,
|
|
) => {
|
|
btMarkerPayloadRef.current = payload;
|
|
const { symbol: curSym, timeframe: curTf } = chartContextRef.current;
|
|
if (payload.symbol !== curSym || payload.timeframe !== curTf) return;
|
|
|
|
const mgr = managerRef.current;
|
|
if (!mgr || !mgr.hasMainSeries()) {
|
|
if (attempt < 120) {
|
|
requestAnimationFrame(() => applyBacktestMarkersWhenReady(payload, attempt + 1));
|
|
}
|
|
return;
|
|
}
|
|
if (payload.symbol !== chartContextRef.current.symbol
|
|
|| payload.timeframe !== chartContextRef.current.timeframe) return;
|
|
mgr.setBacktestMarkers(payload.signals, payload.showPrice);
|
|
}, []);
|
|
|
|
/** reloadAll 직후 등 — payload 가 있을 때만 재적용 (없을 때 clear 하지 않음, 경쟁 방지) */
|
|
const syncBacktestMarkers = useCallback(() => {
|
|
const payload = btMarkerPayloadRef.current;
|
|
if (
|
|
payload &&
|
|
payload.symbol === symbol &&
|
|
payload.timeframe === timeframe &&
|
|
payload.signals.length > 0
|
|
) {
|
|
applyBacktestMarkersWhenReady(payload);
|
|
}
|
|
}, [symbol, timeframe, applyBacktestMarkersWhenReady]);
|
|
|
|
syncBacktestMarkersRef.current = syncBacktestMarkers;
|
|
|
|
// ── 실시간 전략 체크 (가상투자 세션 우선, 없으면 전역+관심종목) ─────────────
|
|
const [showLivePanel, setShowLivePanel] = useState(false);
|
|
const [liveStrategies, setLiveStrategies] = useState<{id:number;name:string}[]>([]);
|
|
const [liveStrategyCandleTypes, setLiveStrategyCandleTypes] = useState<string[] | undefined>();
|
|
const [activeLiveSettings, setActiveLiveSettings] = useState<LiveStrategySettingsDto[]>([]);
|
|
const { session: virtualSession, isVirtualActive, monitoredMarkets: virtualMonitoredMarkets } = useVirtualLiveStrategy();
|
|
|
|
const refreshActiveLiveSettings = useCallback(() => {
|
|
loadActiveLiveStrategySettings()
|
|
.then(setActiveLiveSettings)
|
|
.catch(() => setActiveLiveSettings([]));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!formalLogin) {
|
|
setActiveLiveSettings([]);
|
|
return;
|
|
}
|
|
refreshActiveLiveSettings();
|
|
}, [formalLogin, refreshActiveLiveSettings, isVirtualActive, virtualMonitoredMarkets.join(','), virtualSession.executionType, virtualSession.positionMode]);
|
|
|
|
useEffect(() => {
|
|
const onVirtualChanged = () => refreshActiveLiveSettings();
|
|
window.addEventListener(VIRTUAL_SESSION_CHANGED_EVENT, onVirtualChanged);
|
|
return () => window.removeEventListener(VIRTUAL_SESSION_CHANGED_EVENT, onVirtualChanged);
|
|
}, [refreshActiveLiveSettings]);
|
|
|
|
useEffect(() => {
|
|
if (!appSettingsLoaded || !formalLogin) return;
|
|
if (isVirtualActive) {
|
|
const active = activeLiveSettings.find(s => s.market === symbol);
|
|
setLiveStrategyCandleTypes(active?.strategyCandleTypes);
|
|
return;
|
|
}
|
|
loadLiveStrategySettings(symbol)
|
|
.then(s => setLiveStrategyCandleTypes(s.strategyCandleTypes))
|
|
.catch(() => { /* optional */ });
|
|
}, [symbol, appSettingsLoaded, formalLogin, appDefaults.liveStrategyId, isVirtualActive, activeLiveSettings]);
|
|
|
|
const liveStrategySettings: LiveStrategySettingsDto = useMemo(() => {
|
|
if (isVirtualActive) {
|
|
const active = activeLiveSettings.find(s => s.market === symbol);
|
|
return {
|
|
market: symbol,
|
|
strategyId: active?.strategyId ?? virtualSession.globalStrategyId,
|
|
isLiveCheck: true,
|
|
executionType: active?.executionType ?? virtualSession.executionType,
|
|
positionMode: active?.positionMode ?? virtualSession.positionMode,
|
|
strategyCandleTypes: active?.strategyCandleTypes ?? liveStrategyCandleTypes,
|
|
};
|
|
}
|
|
return {
|
|
market: symbol,
|
|
strategyId: appDefaults.liveStrategyId ?? null,
|
|
isLiveCheck: appDefaults.liveStrategyCheck ?? false,
|
|
executionType: appDefaults.liveExecutionType ?? 'CANDLE_CLOSE',
|
|
positionMode: appDefaults.livePositionMode ?? 'LONG_ONLY',
|
|
strategyCandleTypes: liveStrategyCandleTypes,
|
|
};
|
|
}, [isVirtualActive, activeLiveSettings, symbol, virtualSession, appDefaults, liveStrategyCandleTypes]);
|
|
|
|
/** STOMP 구독 대상 — 가상투자 실행 중이면 투자대상 종목, 아니면 관심종목 */
|
|
const monitoredMarkets = useMemo(() => {
|
|
if (!formalLogin) return [];
|
|
if (isVirtualActive) return virtualMonitoredMarkets;
|
|
if (!appDefaults.liveStrategyCheck || !appDefaults.liveStrategyId) return [];
|
|
const set = new Set(watchlist);
|
|
if (symbol) set.add(symbol);
|
|
return [...set];
|
|
}, [formalLogin, isVirtualActive, virtualMonitoredMarkets, watchlist, symbol, appDefaults.liveStrategyCheck, appDefaults.liveStrategyId]);
|
|
|
|
const [marketSubscriptions, setMarketSubscriptions] = useState<{ market: string; candleType: string }[]>([]);
|
|
|
|
const liveStompSubscriptions = useMemo(() => {
|
|
if (monitoredMarkets.length === 0) return [];
|
|
const filtered = activeLiveSettings.filter(s => monitoredMarkets.includes(s.market));
|
|
if (filtered.length > 0) return expandLiveStrategySubscriptions(filtered);
|
|
if (marketSubscriptions.length > 0) return marketSubscriptions;
|
|
const types = liveStrategyCandleTypes?.length ? liveStrategyCandleTypes : [];
|
|
if (types.length > 0) {
|
|
return monitoredMarkets.flatMap(m => types.map(candleType => ({ market: m, candleType })));
|
|
}
|
|
return [];
|
|
}, [monitoredMarkets, activeLiveSettings, marketSubscriptions, liveStrategyCandleTypes]);
|
|
|
|
useEffect(() => {
|
|
if (!formalLogin) {
|
|
setMarketSubscriptions([]);
|
|
return;
|
|
}
|
|
if (monitoredMarkets.length === 0) {
|
|
setMarketSubscriptions([]);
|
|
return;
|
|
}
|
|
if (isVirtualActive) {
|
|
setMarketSubscriptions(expandLiveStrategySubscriptions(
|
|
activeLiveSettings.filter(s => monitoredMarkets.includes(s.market)),
|
|
));
|
|
return;
|
|
}
|
|
if (!appDefaults.liveStrategyCheck) {
|
|
setMarketSubscriptions([]);
|
|
return;
|
|
}
|
|
loadActiveLiveStrategySettings()
|
|
.then(list => setMarketSubscriptions(expandLiveStrategySubscriptions(list)))
|
|
.catch(() => { /* liveStompSubscriptions 폴백 */ });
|
|
}, [formalLogin, isVirtualActive, appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, monitoredMarkets.join(','), activeLiveSettings]);
|
|
|
|
const handleLiveSettingsChange = useCallback((saved: LiveStrategySettingsDto) => {
|
|
if (isVirtualActive) return;
|
|
saveAppDef({
|
|
liveStrategyCheck: saved.isLiveCheck,
|
|
liveStrategyId: saved.strategyId ?? undefined,
|
|
liveExecutionType: saved.executionType,
|
|
livePositionMode: saved.positionMode,
|
|
});
|
|
if (saved.strategyCandleTypes) {
|
|
setLiveStrategyCandleTypes(saved.strategyCandleTypes);
|
|
}
|
|
if (appDefaults.liveStrategyCheck && saved.isLiveCheck) {
|
|
refreshActiveLiveSettings();
|
|
}
|
|
}, [saveAppDef, appDefaults.liveStrategyCheck, isVirtualActive, refreshActiveLiveSettings]);
|
|
|
|
const prevFavoritesRef = useRef<string[]>(getFavorites());
|
|
|
|
/** 별표 토글 → localStorage·워치리스트 동기화 */
|
|
useEffect(() => {
|
|
const onFavoritesChanged = (e: Event) => {
|
|
const markets = (e as CustomEvent<string[]>).detail ?? getFavorites();
|
|
prevFavoritesRef.current = markets;
|
|
setFavoritesList(markets);
|
|
setWatchlist(markets);
|
|
};
|
|
window.addEventListener(FAVORITES_CHANGED_EVENT, onFavoritesChanged);
|
|
return () => window.removeEventListener(FAVORITES_CHANGED_EVENT, onFavoritesChanged);
|
|
}, []);
|
|
|
|
/** 기존 localStorage 관심종목 → DB 1회 이전 */
|
|
useEffect(() => {
|
|
if (!dbLoaded) return;
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const items = await loadWatchlist();
|
|
if (cancelled) return;
|
|
const dbSyms = items.map(i => i.symbol);
|
|
if (dbSyms.length > 0) {
|
|
setWatchlist(dbSyms);
|
|
setFavoritesList(dbSyms);
|
|
saveFavorites(dbSyms, false);
|
|
return;
|
|
}
|
|
const local = getFavorites();
|
|
if (local.length === 0) return;
|
|
for (const s of local) {
|
|
try { await addWatchlistItem({ symbol: s }); } catch { /* ignore */ }
|
|
}
|
|
const refreshed = await loadWatchlist();
|
|
if (cancelled) return;
|
|
const syms = refreshed.map(i => i.symbol);
|
|
setWatchlist(syms);
|
|
setFavoritesList(syms);
|
|
saveFavorites(syms, false);
|
|
} catch { /* ignore */ }
|
|
})();
|
|
return () => { cancelled = true; };
|
|
}, [dbLoaded]);
|
|
|
|
const liveStrategyName = useMemo(() => {
|
|
const sid = liveStrategySettings.strategyId;
|
|
return sid != null ? liveStrategies.find(s => s.id === sid)?.name ?? null : null;
|
|
}, [liveStrategies, liveStrategySettings.strategyId]);
|
|
|
|
const liveStrategyNameByMarket = useMemo(() => {
|
|
const map: Record<string, string> = {};
|
|
for (const s of activeLiveSettings) {
|
|
if (s.strategyId != null) {
|
|
const name = liveStrategies.find(st => st.id === s.strategyId)?.name;
|
|
if (name) map[s.market] = name;
|
|
}
|
|
}
|
|
return map;
|
|
}, [activeLiveSettings, liveStrategies]);
|
|
|
|
const goToMarketChart = useCallback((market: string) => {
|
|
if (layoutDef.count > 1) {
|
|
slotRefs.current[activeSlot]?.setSymbol(market);
|
|
if (syncOptions.symbol) {
|
|
slotRefs.current.slice(0, layoutDef.count).forEach(r => r?.setSymbol(market));
|
|
}
|
|
} else {
|
|
setSymbol(market);
|
|
setLegend(null);
|
|
}
|
|
}, [layoutDef.count, activeSlot, syncOptions.symbol]);
|
|
|
|
// 전략 목록 로드 (실시간 패널용)
|
|
// menuPage가 chart로 복귀하거나 실시간 패널이 열릴 때마다 재로드하여
|
|
// 전략 설정 화면에서 추가/수정된 전략이 즉시 반영되도록 한다.
|
|
const refreshLiveStrategies = useCallback(() => {
|
|
loadStrategiesForLive()
|
|
.then(list => {
|
|
const rows = list
|
|
.filter(s => s.id != null)
|
|
.map(s => ({ id: s.id as number, name: s.name }));
|
|
setLiveStrategies(rows);
|
|
const sid = appDefaults.liveStrategyId;
|
|
if (sid != null && !rows.some(r => r.id === sid)) {
|
|
saveAppDef({ liveStrategyId: undefined, liveStrategyCheck: false });
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}, [appDefaults.liveStrategyId, saveAppDef]);
|
|
|
|
useEffect(() => {
|
|
if (!formalLogin) {
|
|
setLiveStrategies([]);
|
|
return;
|
|
}
|
|
refreshLiveStrategies();
|
|
}, [menuPage, showLivePanel, formalLogin, refreshLiveStrategies]);
|
|
|
|
const liveNotifierRef = useRef<LiveSignalNotifierHandle>(null);
|
|
const liveMarkersRef = useRef<LiveMarker[]>([]);
|
|
const clearLiveMarkers = useCallback(() => {
|
|
liveMarkersRef.current = [];
|
|
liveNotifierRef.current?.clearMarkers();
|
|
managerRef.current?.clearLiveStrategyMarkers();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadBacktestSettings().then(s => { if (s) setBtSettings(s); });
|
|
}, []);
|
|
|
|
// 단일차트 모드: 과거 데이터 추가 로드
|
|
const { isLoadingMore: isSingleLoadingMore, loadMoreRef: singleLoadMoreRef } = useHistoryLoader({
|
|
symbol, timeframe, isUpbit: useUpbit, managerRef,
|
|
});
|
|
|
|
// 개발 환경: 브라우저 콘솔에서 window.__chartMgr__.logIndicatorLastValues() 로
|
|
// 각 지표의 실제 계산값을 확인 가능 (갱신 여부 진단용)
|
|
useEffect(() => {
|
|
if (import.meta.env.DEV) {
|
|
(window as unknown as Record<string, unknown>)['__chartMgr__'] = managerRef;
|
|
}
|
|
}, []);
|
|
|
|
// 멀티 레이아웃: 각 슬롯 ref 배열 (최대 8개)
|
|
const slotRefs = useRef<(ChartSlotHandle | null)[]>(Array(8).fill(null));
|
|
|
|
useEffect(() => {
|
|
if (!appSettingsLoaded) return;
|
|
const tf = layoutDef.count > 1 ? activeSlotTf : timeframe;
|
|
managerRef.current?.setChartTimeFormat(chartTimeFormat);
|
|
slotRefs.current.forEach(slot => slot?.setChartTimeFormat?.(chartTimeFormat, tf));
|
|
}, [appSettingsLoaded, chartTimeFormat, timeframe, activeSlotTf, layoutDef.count]);
|
|
|
|
useEffect(() => {
|
|
if (!appSettingsLoaded) return;
|
|
managerRef.current?.setCandleAreaPriceLabelsEnabled(chartCandleAreaPriceLabels);
|
|
managerRef.current?.setIndicatorAreaPriceLabelsEnabled(chartSeriesPriceLabels);
|
|
slotRefs.current.forEach(slot => {
|
|
slot?.setCandleAreaPriceLabelsEnabled(chartCandleAreaPriceLabels);
|
|
slot?.setIndicatorAreaPriceLabelsEnabled(chartSeriesPriceLabels);
|
|
});
|
|
}, [appSettingsLoaded, chartCandleAreaPriceLabels, chartSeriesPriceLabels]);
|
|
|
|
useEffect(() => {
|
|
if (!appSettingsLoaded) return;
|
|
const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null;
|
|
const h = wrapper?.clientHeight;
|
|
managerRef.current?.setVolumeVisible(chartVolumeVisible, h);
|
|
slotRefs.current.forEach(slot => slot?.setVolumeVisible(chartVolumeVisible));
|
|
}, [appSettingsLoaded, chartVolumeVisible]);
|
|
|
|
useEffect(() => {
|
|
if (!appSettingsLoaded) return;
|
|
const apply = () => {
|
|
managerRef.current?.setPaneSeparatorOptions(chartPaneSeparator);
|
|
managerRef.current?.refreshPaneSeparatorOverlay();
|
|
slotRefs.current.forEach(slot => slot?.setPaneSeparatorOptions(chartPaneSeparator));
|
|
};
|
|
apply();
|
|
if (!chartVisible) return;
|
|
const raf = requestAnimationFrame(() => requestAnimationFrame(apply));
|
|
return () => cancelAnimationFrame(raf);
|
|
}, [appSettingsLoaded, menuPage, chartPaneSeparator]);
|
|
|
|
// mainChartStyle 변경 → ChartManager 즉시 적용
|
|
useEffect(() => {
|
|
managerRef.current?.updateMainSeriesStyle(mainChartStyle);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [mainChartStyle]);
|
|
|
|
// 종목·타임프레임 변경 시 이전 백테스팅 결과·마커 제거 + 진행 중 run 무효화
|
|
useEffect(() => {
|
|
btInvalidateRuns();
|
|
btMarkerPayloadRef.current = null;
|
|
btClear();
|
|
managerRef.current?.clearBacktestMarkers();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [symbol, timeframe]);
|
|
|
|
// 백테스팅 시그널 → ref 갱신 후 ChartManager 마커 동기화
|
|
useEffect(() => {
|
|
if (btMatchesChart && btSignals.length > 0) {
|
|
const payload = {
|
|
symbol,
|
|
timeframe,
|
|
signals: btSignals,
|
|
showPrice: appDefaults.btShowPrice ?? false,
|
|
};
|
|
btMarkerPayloadRef.current = payload;
|
|
applyBacktestMarkersWhenReady(payload);
|
|
} else {
|
|
btMarkerPayloadRef.current = null;
|
|
managerRef.current?.clearBacktestMarkers();
|
|
}
|
|
let innerRaf = 0;
|
|
const outerRaf = requestAnimationFrame(() => {
|
|
innerRaf = requestAnimationFrame(syncBacktestMarkers);
|
|
});
|
|
return () => {
|
|
cancelAnimationFrame(outerRaf);
|
|
if (innerRaf) cancelAnimationFrame(innerRaf);
|
|
};
|
|
}, [btMatchesChart, btSignals, btRunSeq, symbol, timeframe, appDefaults.btShowPrice, applyBacktestMarkersWhenReady, syncBacktestMarkers]);
|
|
|
|
// 금액 표시 토글 변경 시 실시간 시그널 마커 텍스트 재적용
|
|
useEffect(() => {
|
|
if (!chartVisible || liveMarkersRef.current.length === 0) return;
|
|
managerRef.current?.setLiveStrategyMarkers(liveMarkersRef.current, appDefaults.btShowPrice);
|
|
}, [chartVisible, appDefaults.btShowPrice]);
|
|
|
|
// ── 시뮬레이션 데이터 (Upbit 아닐 때) ──────────────────────────────────
|
|
const [simBars, setSimBars] = useState<OHLCVBar[]>(() =>
|
|
!isUpbitMarket(initial.symbol) ? generateBars(initial.symbol, initial.timeframe) : []
|
|
);
|
|
useEffect(() => {
|
|
if (useUpbit) return;
|
|
const b = generateBars(symbol, timeframe);
|
|
setSimBars(b);
|
|
setStats(calcStats(b));
|
|
setLegend(null);
|
|
}, [symbol, timeframe, useUpbit]);
|
|
|
|
// ── Upbit 실시간 데이터 ─────────────────────────────────────────────────
|
|
// 틱 수신: 동일 캔들 업데이트 → React state 변경 없이 차트만 직접 갱신
|
|
const handleTickUpdate = useCallback((bar: OHLCVBar) => {
|
|
if (barsMarketRef.current !== chartContextRef.current.symbol) return;
|
|
if (!chartLiveReadyRef.current) {
|
|
pendingRealtimeBarRef.current = bar;
|
|
return;
|
|
}
|
|
if (!managerRef.current) {
|
|
pendingRealtimeBarRef.current = bar;
|
|
flashWsDot();
|
|
return;
|
|
}
|
|
managerRef.current.updateBar(bar);
|
|
flashWsDot(); // 수신 중 형광 glow (DOM 직접 조작, re-render 없음)
|
|
}, []);
|
|
|
|
// 새 캔들: bars state를 변경하지 않고 차트에 바만 추가 + 인디케이터 last point 갱신
|
|
const handleNewCandle = useCallback((newBar: OHLCVBar) => {
|
|
if (barsMarketRef.current !== chartContextRef.current.symbol) return;
|
|
if (!chartLiveReadyRef.current) {
|
|
pendingRealtimeBarRef.current = newBar;
|
|
return;
|
|
}
|
|
if (!managerRef.current) {
|
|
pendingRealtimeBarRef.current = newBar;
|
|
flashWsDot();
|
|
return;
|
|
}
|
|
managerRef.current.appendBar(newBar);
|
|
flashWsDot();
|
|
}, []);
|
|
|
|
const handleCandlesReady = useCallback(() => {
|
|
chartLiveReadyRef.current = true;
|
|
setSingleChartPaintReady(true);
|
|
const pending = pendingRealtimeBarRef.current;
|
|
const mgr = managerRef.current;
|
|
if (!pending || !mgr || barsMarketRef.current !== chartContextRef.current.symbol) return;
|
|
pendingRealtimeBarRef.current = null;
|
|
mgr.updateBar(pending);
|
|
}, []);
|
|
|
|
const handleSingleChartPaintReadyChange = useCallback((ready: boolean) => {
|
|
setSingleChartPaintReady(ready);
|
|
}, []);
|
|
|
|
const handleSlotPaintReadyChange = useCallback((slotIndex: number, ready: boolean) => {
|
|
slotPaintReadyRef.current[slotIndex] = ready;
|
|
setSlotPaintEpoch(v => v + 1);
|
|
}, []);
|
|
|
|
const chartRealtimeSource = appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP';
|
|
const chartConfigReady = appSettingsLoaded && dbLoaded && chartBootstrapComplete;
|
|
const { bars: upbitBars, barsMarket: upbitBarsMarket, latestBar, wsStatus, isLoading, error } = useChartRealtimeData(
|
|
symbol,
|
|
timeframe,
|
|
useMemo(() => ({
|
|
onTickUpdate: handleTickUpdate,
|
|
onNewCandle: handleNewCandle,
|
|
enabled: chartScreenActive && useUpbit && chartConfigReady,
|
|
source: chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT',
|
|
}), [handleTickUpdate, handleNewCandle, chartScreenActive, useUpbit, chartConfigReady, chartRealtimeSource]),
|
|
);
|
|
|
|
// 실제로 차트에 넘길 bars (초기 200개, 실시간 수신 후에도 변경 없음)
|
|
const bars = useUpbit ? upbitBars : simBars;
|
|
const barsMarket = useUpbit ? upbitBarsMarket : symbol;
|
|
barsMarketRef.current = barsMarket;
|
|
|
|
const chartBtReportModel = useMemo(() => {
|
|
if (!btMatchesChart) return null;
|
|
const reportSymbol = btSymbol ?? symbol;
|
|
const reportTf = btTimeframe ?? timeframe;
|
|
return buildChartBacktestReportModel({
|
|
analysis: btAnalysis,
|
|
stats: btStats,
|
|
signals: btSignals,
|
|
strategyName: btStrategyName,
|
|
symbol: reportSymbol,
|
|
timeframe: reportTf,
|
|
barCount: bars.length,
|
|
createdAt: btReportCreatedAt,
|
|
});
|
|
}, [
|
|
btMatchesChart,
|
|
btAnalysis,
|
|
btStats,
|
|
btSignals,
|
|
btStrategyName,
|
|
btSymbol,
|
|
btTimeframe,
|
|
symbol,
|
|
timeframe,
|
|
bars.length,
|
|
btReportCreatedAt,
|
|
]);
|
|
|
|
/** 종목·타임프레임 전환 시 이전 틱 버퍼·실시간 허용 플래그 초기화 (paint 전에 실행) */
|
|
useLayoutEffect(() => {
|
|
chartLiveReadyRef.current = false;
|
|
pendingRealtimeBarRef.current = null;
|
|
setSingleChartPaintReady(false);
|
|
}, [symbol, timeframe]);
|
|
|
|
useEffect(() => {
|
|
slotPaintReadyRef.current = {};
|
|
setSlotPaintEpoch(v => v + 1);
|
|
setSingleChartPaintReady(false);
|
|
}, [layoutDef.count, sessionKey]);
|
|
|
|
// Upbit 초기 로딩 완료 시 stats 업데이트
|
|
useEffect(() => {
|
|
if (useUpbit && upbitBars.length > 0) {
|
|
setStats(calcStats(upbitBars));
|
|
setLegend(null);
|
|
}
|
|
}, [useUpbit, upbitBars]);
|
|
|
|
// ── Watchlist 가격 맵 ──────────────────────────────────────────────────
|
|
const priceMap = useMemo(() => {
|
|
// 시세 패널이 열려 있고 ticker가 활성 상태일 때만 계산 (데모 심볼용)
|
|
if (!showMarketPanel || !tickerFeedActive) return {} as Record<string, { price: number; changePct: number }>;
|
|
const map: Record<string, { price: number; changePct: number }> = {};
|
|
for (const sym of watchlist) {
|
|
const b = isUpbitMarket(sym) ? [] : generateBars(sym, '1D');
|
|
if (b.length >= 2) {
|
|
map[sym] = { price: b[b.length-1].close, changePct: (b[b.length-1].close - b[b.length-2].close) / b[b.length-2].close * 100 };
|
|
}
|
|
}
|
|
return map;
|
|
}, [watchlist, showMarketPanel, tickerFeedActive]);
|
|
|
|
|
|
// ── DB 워크스페이스 동기화 ─────────────────────────────────────────────
|
|
const workspaceState = useMemo(() => ({
|
|
layoutId: layoutDef.id,
|
|
syncOptions,
|
|
slots: [{
|
|
slotIndex: 0,
|
|
symbol, timeframe, chartType, mode, logScale,
|
|
indicators, drawings, drawingsLocked, drawingsVisible,
|
|
mainChartStyle,
|
|
}],
|
|
}), [layoutDef.id, syncOptions, symbol, timeframe, chartType,
|
|
mode, logScale, indicators, drawings, drawingsLocked, drawingsVisible, mainChartStyle]);
|
|
|
|
const { isLoaded: _wsLoaded } = useWorkspacePersist({
|
|
sessionKey,
|
|
state: workspaceState,
|
|
onLoad: (data) => {
|
|
try {
|
|
if (data.layoutId) {
|
|
const def = LAYOUTS.find(l => l.id === data.layoutId);
|
|
if (def) setLayoutDef(def);
|
|
}
|
|
if (data.syncOptions) setSyncOptions(data.syncOptions as SyncOptions);
|
|
const slot0 = Array.isArray(data.slots) && data.slots.length > 0
|
|
? (data.slots[0] as Record<string, unknown>)
|
|
: null;
|
|
if (slot0) {
|
|
if (slot0.symbol) setSymbol(slot0.symbol as string);
|
|
if (slot0.timeframe) setTimeframe(slot0.timeframe as Timeframe);
|
|
if (slot0.chartType) setChartType(slot0.chartType as ChartType);
|
|
if (slot0.mode) setMode(slot0.mode as ChartMode);
|
|
if (slot0.logScale != null) setLogScale(Boolean(slot0.logScale));
|
|
if (Array.isArray(slot0.indicators)) {
|
|
const loaded = (slot0.indicators as IndicatorConfig[]).map(ind => {
|
|
if (ind.type === 'SMA') return normalizeSmaConfig(ind);
|
|
if (ind.type === 'IchimokuCloud') return normalizeIchimokuConfig(ind);
|
|
return ind;
|
|
});
|
|
setIndicators(sortIndicatorsByTypeOrder(loaded));
|
|
}
|
|
if (Array.isArray(slot0.drawings)) setDrawings(slot0.drawings as Drawing[]);
|
|
if (slot0.drawingsLocked != null) setDrawingsLocked(Boolean(slot0.drawingsLocked));
|
|
if (slot0.drawingsVisible != null) setDrawingsVisible(Boolean(slot0.drawingsVisible));
|
|
if (slot0.mainChartStyle) setMainChartStyle(slot0.mainChartStyle as MainChartStyle);
|
|
}
|
|
// 슬롯 1+ 초기 props 수집 (멀티슬롯 전환 시 각 ChartSlot 에 전달)
|
|
if (Array.isArray(data.slots) && data.slots.length > 1) {
|
|
const extra: typeof slotInitProps = {};
|
|
for (let i = 1; i < data.slots.length; i++) {
|
|
const s = data.slots[i] as Record<string, unknown>;
|
|
extra[i] = {
|
|
symbol: s.symbol as string | undefined,
|
|
timeframe: s.timeframe as Timeframe | undefined,
|
|
indicators: Array.isArray(s.indicators)
|
|
? sortIndicatorsByTypeOrder(
|
|
(s.indicators as IndicatorConfig[]).map(ind => {
|
|
if (ind.type === 'SMA') return normalizeSmaConfig(ind);
|
|
if (ind.type === 'IchimokuCloud') return normalizeIchimokuConfig(ind);
|
|
return ind;
|
|
}),
|
|
)
|
|
: undefined,
|
|
mainChartStyle: s.mainChartStyle as MainChartStyle | undefined,
|
|
};
|
|
}
|
|
setSlotInitProps(extra);
|
|
}
|
|
} catch (e) { console.warn('[DB load] parse error', e); }
|
|
setDbLoaded(true);
|
|
},
|
|
onWatchlistLoad: (items: WatchlistItem[]) => {
|
|
const syms = items.map(i => i.symbol);
|
|
initFavoritesFromDb(syms);
|
|
if (syms.length > 0) {
|
|
setWatchlist(syms);
|
|
setFavoritesList(syms);
|
|
}
|
|
},
|
|
noAutoSave: !dbLoaded,
|
|
});
|
|
|
|
/** 로그인·로그아웃 시에만 차트 재마운트 (종목·타임프레임은 TradingChart 내부 reloadAll로 처리) */
|
|
const chartMountKey = useMemo(() => sessionKey, [sessionKey]);
|
|
|
|
// DB에 워크스페이스 없음(204) → appDefaults 레이아웃 적용 후 저장 허용
|
|
useEffect(() => {
|
|
if (!_wsLoaded || dbLoaded) return;
|
|
setDbLoaded(true);
|
|
const def = LAYOUTS.find(l => l.id === appDefaults.defaultLayoutId);
|
|
if (def) setLayoutDef(def);
|
|
}, [_wsLoaded, dbLoaded, appDefaults.defaultLayoutId]);
|
|
|
|
// ── 키보드 단축키 ─────────────────────────────────────────────────────
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
|
if (e.key === 'Escape') {
|
|
setDrawingTool('cursor');
|
|
setSelectedDrawingId(null);
|
|
setDrawingCtxToolbar(null);
|
|
}
|
|
if (e.key === 'f' || e.key === 'F') managerRef.current?.fitContent();
|
|
if (e.key === 'l' || e.key === 'L') setLogScale(v => !v);
|
|
if (e.altKey && e.key === 's') handleScreenshot();
|
|
if (mode === 'trading') {
|
|
if (e.key === 'h' || e.key === 'H') setDrawingTool('hline');
|
|
if (e.key === 'v' || e.key === 'V') setDrawingTool('vline');
|
|
if (e.key === 't' || e.key === 'T') setDrawingTool('trendline');
|
|
if (e.key === 'm' || e.key === 'M') setDrawingTool('measure');
|
|
if (e.key === 'r' || e.key === 'R') setDrawingTool('rect');
|
|
if (e.key === 'z' && !e.ctrlKey) setDrawingTool('fibtz');
|
|
if (e.ctrlKey && e.key === 'z') { e.preventDefault(); handleUndo(); }
|
|
if (e.ctrlKey && (e.key === 'y' || (e.shiftKey && e.key === 'Z'))) { e.preventDefault(); handleRedo(); }
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handler);
|
|
return () => window.removeEventListener('keydown', handler);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [mode]);
|
|
|
|
useEffect(() => {
|
|
if (mode === 'chart' && drawingTool !== 'zoom' && drawingTool !== 'magnifier') {
|
|
setDrawingTool('cursor');
|
|
}
|
|
}, [mode]);
|
|
|
|
useEffect(() => {
|
|
if (!chartVisible) {
|
|
setShowMarketPanel(false);
|
|
setMobileRightOpen(false);
|
|
}
|
|
}, [menuPage]);
|
|
|
|
useEffect(() => {
|
|
if (!isMobile) setMobileRightOpen(false);
|
|
}, [isMobile]);
|
|
|
|
// percentScale / showGrid 변경 시 ChartManager 에 즉시 반영
|
|
useEffect(() => { managerRef.current?.setPercentScale(percentScale); }, [percentScale]);
|
|
useEffect(() => { managerRef.current?.setGrid(showGrid); }, [showGrid]);
|
|
|
|
// ── 핸들러 ────────────────────────────────────────────────────────────
|
|
|
|
/** Time sync: 한 슬롯의 가시 시간 범위가 바뀌면 나머지에 전파 */
|
|
const handleVisibleRangeChange = useCallback((r: { from: number; to: number }) => {
|
|
if (syncOptions.time) setSyncVisibleRange(r);
|
|
}, [syncOptions.time]);
|
|
|
|
/** Date range sync: 한 슬롯의 가시 논리 범위가 바뀌면 나머지에 전파 */
|
|
const handleLogicalRangeChange = useCallback((r: { from: number; to: number }) => {
|
|
if (syncOptions.dateRange) setSyncLogicalRange(r);
|
|
}, [syncOptions.dateRange]);
|
|
|
|
/**
|
|
* 멀티차트 슬롯 전체 보기:
|
|
* 해당 슬롯의 심볼/타임프레임/지표를 싱글 차트에 적용하고 싱글 레이아웃으로 전환한다.
|
|
*/
|
|
const handleSlotFullView = useCallback((slotSymbol: string, slotIdx: number) => {
|
|
const slot = slotRefs.current[slotIdx];
|
|
if (slot) {
|
|
setSymbol(slot.getSymbol());
|
|
setTimeframe(slot.getTimeframe());
|
|
setIndicators(slot.getIndicators().map(i => ({ ...i })));
|
|
} else {
|
|
setSymbol(slotSymbol);
|
|
}
|
|
setLegend(null);
|
|
setSlot0InitProps(null);
|
|
setLayoutDef(LAYOUTS[0]); // 싱글 레이아웃(id: '1')
|
|
}, []);
|
|
|
|
// ── 보조지표 단독 전체화면 모드 ────────────────────────────────────────────
|
|
const [focusedIndicatorId, setFocusedIndicatorId] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
setFocusedIndicatorId(null);
|
|
}, [sessionKey]);
|
|
|
|
/** 단독 전체화면에서 보여줄 지표 목록 (overlay + 선택된 1개) */
|
|
const effectiveIndicators = useMemo(() => {
|
|
if (!focusedIndicatorId || layoutDef.count > 1) return indicators;
|
|
return indicators.filter(ind => {
|
|
const def = getIndicatorDef(ind.type);
|
|
return def?.overlay || def?.returnsMarkers || ind.id === focusedIndicatorId;
|
|
});
|
|
}, [focusedIndicatorId, indicators, layoutDef.count]);
|
|
|
|
useEffect(() => {
|
|
setSingleChartPaintReady(false);
|
|
}, [effectiveIndicators]);
|
|
|
|
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 [chartPriceExtremeLabelsVisible, setChartPriceExtremeLabelsVisible] = useState(
|
|
() => loadChartPriceExtremeLabelsVisible(),
|
|
);
|
|
|
|
useEffect(() => {
|
|
managerRef.current?.setPriceExtremeLabelsVisible(chartPriceExtremeLabelsVisible);
|
|
slotRefs.current.forEach(slot => slot?.setPriceExtremeLabelsVisible(chartPriceExtremeLabelsVisible));
|
|
}, [chartPriceExtremeLabelsVisible]);
|
|
|
|
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 handleTogglePriceExtremeLabels = useCallback(() => {
|
|
setChartPriceExtremeLabelsVisible(prev => {
|
|
const next = !prev;
|
|
saveChartPriceExtremeLabelsVisible(next);
|
|
managerRef.current?.setPriceExtremeLabelsVisible(next);
|
|
slotRefs.current.forEach(slot => slot?.setPriceExtremeLabelsVisible(next));
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
/**
|
|
* 보조지표 순서 변경 (드래그 핸들): fromId 를 indicators 배열에서 이동.
|
|
* overlay/markers 지표는 건드리지 않고 비오버레이 지표 위치만 변경됨.
|
|
*/
|
|
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));
|
|
}, []);
|
|
|
|
/**
|
|
* 지표 추가:
|
|
* - 싱글 레이아웃: 메인 차트에 추가
|
|
* - 멀티 레이아웃: 활성 슬롯(ChartSlot ref)에 추가
|
|
*/
|
|
const applyChartIndicators = useCallback((chartIndicators: IndicatorConfig[]) => {
|
|
if (layoutDef.count > 1) {
|
|
slotRefs.current[activeSlot]?.setIndicators(chartIndicators);
|
|
setActiveSlotIndicators(chartIndicators.map(i => ({ ...i })));
|
|
} else {
|
|
setIndicators(chartIndicators);
|
|
}
|
|
}, [layoutDef.count, activeSlot]);
|
|
|
|
/** 설정 목록 순서 변경 → 차트 pane 순서 동기화 */
|
|
const handleIndicatorListOrderChange = useCallback((orderedTypes: string[]) => {
|
|
const resort = (inds: IndicatorConfig[]) => sortIndicatorsByTypeOrder(inds, orderedTypes);
|
|
if (layoutDef.count > 1) {
|
|
const slotRef = slotRefs.current[activeSlot];
|
|
if (!slotRef) return;
|
|
const next = resort(slotRef.getIndicators());
|
|
slotRef.setIndicators(next);
|
|
setActiveSlotIndicators(next.map(i => ({ ...i })));
|
|
} else {
|
|
setIndicators(prev => resort(prev));
|
|
}
|
|
}, [layoutDef.count, activeSlot]);
|
|
|
|
const handleAddIndicator = useCallback((type: string, fromConfig?: IndicatorConfig) => {
|
|
if (layoutDef.count > 1) {
|
|
const slotRef = slotRefs.current[activeSlot];
|
|
if (slotRef) { slotRef.addIndicator(type, fromConfig); return; }
|
|
}
|
|
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, plotVisibility } = getVisualConfig(type, def.plots, def.hlines);
|
|
cfg = {
|
|
id: newIndId(),
|
|
type: def.type,
|
|
params,
|
|
plots,
|
|
hlines,
|
|
...(plotVisibility ? { plotVisibility } : {}),
|
|
...(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]);
|
|
});
|
|
}, [layoutDef.count, activeSlot, getParams, getVisualConfig]);
|
|
|
|
/** 지표 추가 팝업 — 탭 전체 추가 (탭 목록 순서 유지) */
|
|
const handleAddIndicators = useCallback((types: string[]) => {
|
|
if (!types.length) return;
|
|
const orderedTypes = types.filter(t => getIndicatorDef(t));
|
|
if (orderedTypes.length === 0) return;
|
|
if (layoutDef.count > 1) {
|
|
const slotRef = slotRefs.current[activeSlot];
|
|
if (slotRef) {
|
|
slotRef.addIndicators(orderedTypes);
|
|
return;
|
|
}
|
|
}
|
|
setIndicators(
|
|
sortIndicatorsByTypeOrder(
|
|
replaceIndicatorConfigsFromTypes(orderedTypes, getParams, getVisualConfig, newIndId),
|
|
orderedTypes,
|
|
),
|
|
);
|
|
}, [layoutDef.count, activeSlot, getParams, getVisualConfig]);
|
|
|
|
/**
|
|
* 지표 제거 (type 기준):
|
|
* - 싱글: 메인 차트에서 제거
|
|
* - 멀티: 활성 슬롯에서 제거
|
|
*/
|
|
const handleRemoveByType = useCallback((type: string) => {
|
|
if (layoutDef.count > 1) {
|
|
const slotRef = slotRefs.current[activeSlot];
|
|
if (slotRef) { slotRef.removeIndicatorByType(type); return; }
|
|
}
|
|
setIndicators(prev => removeIndicatorTypeFromList(prev, type));
|
|
}, [layoutDef.count, activeSlot]);
|
|
|
|
/**
|
|
* 타임프레임 변경:
|
|
* - 싱글: 메인 차트 변경
|
|
* - 멀티: 활성 슬롯 변경 (Interval Sync 시 전체 슬롯)
|
|
*/
|
|
// magnetMode 변경 → 싱글차트 + 멀티슬롯 전체 크로스헤어 모드 즉시 반영
|
|
useEffect(() => {
|
|
const enabled = magnetMode !== 'off';
|
|
// 싱글 레이아웃 메인 차트
|
|
managerRef.current?.setCrosshairMagnet(enabled);
|
|
// 멀티 레이아웃 슬롯 전체
|
|
slotRefs.current.forEach(slot => slot?.setCrosshairMagnet(enabled));
|
|
}, [magnetMode]);
|
|
|
|
const handleTimeframe = useCallback((tf: Timeframe) => {
|
|
if (layoutDef.count > 1) {
|
|
slotRefs.current[activeSlot]?.setTimeframe(tf);
|
|
setActiveSlotTf(tf);
|
|
if (syncOptions.interval) {
|
|
slotRefs.current.slice(0, layoutDef.count).forEach(r => r?.setTimeframe(tf));
|
|
}
|
|
} else {
|
|
setTimeframe(tf);
|
|
}
|
|
// 타임프레임 변경 → DB 기본값 갱신
|
|
saveAppDef({ defaultTimeframe: tf });
|
|
}, [layoutDef.count, activeSlot, syncOptions.interval, saveAppDef]);
|
|
|
|
const chartIndicatorsForSettings = layoutDef.count > 1 ? activeSlotIndicators : indicators;
|
|
|
|
/** 지표 추가 팝업 — 항목별 설정 모달 */
|
|
const handleOpenIndicatorSettings = useCallback((type: string) => {
|
|
const onChart = chartIndicatorsForSettings.find(i => i.type === type);
|
|
setSettingsModalId(onChart ? onChart.id : indicatorSettingsTemplateId(type));
|
|
setCtxToolbar(null);
|
|
}, [chartIndicatorsForSettings]);
|
|
|
|
/** 지표 설정 저장 (params/plots/plotVisibility 변경) → 자동으로 재렌더링 */
|
|
const handleIndicatorSave = useCallback((updated: IndicatorConfig) => {
|
|
const fromTemplate =
|
|
isIndicatorSettingsTemplateId(settingsModalId ?? '')
|
|
|| updated.id.startsWith('template_');
|
|
markChartIndicatorSaveCommitted();
|
|
saveParams(updated.type, updated.params);
|
|
saveVisual(
|
|
updated.type,
|
|
updated.plots,
|
|
updated.hlines,
|
|
updated.cloudColors,
|
|
updated.bandBackground,
|
|
updated.plotVisibility,
|
|
);
|
|
|
|
const applyUpdate = (prev: IndicatorConfig[]): IndicatorConfig[] => {
|
|
if (fromTemplate) {
|
|
const merged = enrichIndicatorConfig(updated);
|
|
return prev.map(ind =>
|
|
ind.type === updated.type
|
|
? { ...merged, id: ind.id, hidden: ind.hidden }
|
|
: ind,
|
|
);
|
|
}
|
|
return prev.map(ind => (ind.id === updated.id ? updated : ind));
|
|
};
|
|
|
|
if (layoutDef.count > 1) {
|
|
applyChartIndicators(applyUpdate(activeSlotIndicators));
|
|
} else {
|
|
setIndicators(applyUpdate);
|
|
}
|
|
setSettingsModalId(null);
|
|
setCtxToolbar(null);
|
|
}, [
|
|
saveParams,
|
|
saveVisual,
|
|
settingsModalId,
|
|
layoutDef.count,
|
|
activeSlotIndicators,
|
|
applyChartIndicators,
|
|
]);
|
|
|
|
const bulkSettingsIndicators = layoutDef.count > 1 ? activeSlotIndicators : indicators;
|
|
|
|
/** 설정 화면에서 DB 기본값 저장 후 차트 복귀 시 — 전역 기본값을 차트 인스턴스에 일괄 반영 */
|
|
const lastSyncedSettingsRevisionRef = useRef(-1);
|
|
const syncChartsFromSavedDefaults = useCallback(() => {
|
|
const merge = (inds: IndicatorConfig[]) =>
|
|
mergeGlobalDefaultsOntoChartIndicators(inds, getParams, getVisualConfig);
|
|
|
|
if (layoutDef.count > 1) {
|
|
slotRefs.current.slice(0, layoutDef.count).forEach(slot => {
|
|
if (!slot) return;
|
|
const current = slot.getIndicators();
|
|
const next = merge(current);
|
|
if (chartIndicatorsNeedDefaultsRefresh(current, next)) {
|
|
slot.setIndicators(next);
|
|
}
|
|
});
|
|
const activeInds = slotRefs.current[activeSlot]?.getIndicators() ?? activeSlotIndicators;
|
|
const mergedActive = merge(activeInds);
|
|
if (chartIndicatorsNeedDefaultsRefresh(activeInds, mergedActive)) {
|
|
setActiveSlotIndicators(mergedActive.map(i => ({ ...i })));
|
|
}
|
|
} else {
|
|
setIndicators(prev => {
|
|
const next = merge(prev);
|
|
return chartIndicatorsNeedDefaultsRefresh(prev, next) ? next : prev;
|
|
});
|
|
}
|
|
}, [layoutDef.count, activeSlot, activeSlotIndicators, getParams, getVisualConfig]);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!appSettingsLoaded || !dbLoaded) {
|
|
setChartBootstrapComplete(false);
|
|
return;
|
|
}
|
|
if (chartVisible && indicatorSettingsRevision > lastSyncedSettingsRevisionRef.current) {
|
|
if (consumeSkipNextChartDefaultsSync()) {
|
|
lastSyncedSettingsRevisionRef.current = indicatorSettingsRevision;
|
|
} else {
|
|
syncChartsFromSavedDefaults();
|
|
lastSyncedSettingsRevisionRef.current = indicatorSettingsRevision;
|
|
}
|
|
}
|
|
setChartBootstrapComplete(true);
|
|
}, [appSettingsLoaded, dbLoaded, chartVisible, indicatorSettingsRevision, syncChartsFromSavedDefaults]);
|
|
|
|
useEffect(() => {
|
|
setChartBootstrapComplete(false);
|
|
lastSyncedSettingsRevisionRef.current = -1;
|
|
}, [sessionKey]);
|
|
|
|
/** 보조지표 일괄 설정 적용 — 전체 지표 DB + 차트 인스턴스 병합 */
|
|
const handleBulkIndicatorApply = useCallback((result: {
|
|
chartIndicators: IndicatorConfig[];
|
|
allConfigs: IndicatorConfig[];
|
|
}) => {
|
|
const { chartIndicators, allConfigs } = result;
|
|
applyChartIndicators(sortIndicatorsByTypeOrder(chartIndicators));
|
|
allConfigs.forEach(ind => {
|
|
saveParams(ind.type, ind.params);
|
|
saveVisual(ind.type, ind.plots, ind.hlines, ind.cloudColors, ind.bandBackground, ind.plotVisibility);
|
|
});
|
|
setShowBulkIndSettings(false);
|
|
}, [applyChartIndicators, saveParams, saveVisual]);
|
|
|
|
/** 지표 숨김/표시 토글 */
|
|
const handleToggleIndicatorHidden = useCallback((id: string) => {
|
|
setIndicators(prev => prev.map(ind =>
|
|
ind.id === id ? { ...ind, hidden: !ind.hidden } : ind
|
|
));
|
|
}, []);
|
|
|
|
/** 지표 ID로 삭제 */
|
|
const handleDuplicateIndicator = useCallback((sourceId: string) => {
|
|
if (layoutDef.count > 1) {
|
|
slotRefs.current[activeSlot]?.duplicateIndicator(sourceId);
|
|
return;
|
|
}
|
|
setIndicators(prev => duplicateIndicatorInList(sourceId, prev, newIndId) ?? prev);
|
|
}, [layoutDef.count, activeSlot]);
|
|
|
|
const handleRemoveIndicatorById = useCallback((id: string) => {
|
|
setIndicators(prev => removeIndicatorFromList(prev, id));
|
|
setFocusedIndicatorId(prev => prev === id ? null : prev);
|
|
setCtxToolbar(null);
|
|
setSettingsModalId(null);
|
|
}, []);
|
|
|
|
const handleScreenshot = () => {
|
|
const url = managerRef.current?.screenshot();
|
|
if (!url) return;
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `chart_${symbol}_${timeframe}_${Date.now()}.png`;
|
|
a.click();
|
|
};
|
|
|
|
const handleFullscreen = () => {
|
|
if (!document.fullscreenElement) document.documentElement.requestFullscreen().catch(() => {});
|
|
else document.exitFullscreen().catch(() => {});
|
|
};
|
|
|
|
// 실시간 모드: latestBar 우선, 없으면 마지막 bars 사용
|
|
const lastKnownBar = useUpbit
|
|
? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null))
|
|
: (bars.length > 0 ? bars[bars.length - 1] : null);
|
|
/** 크로스헤어 OHLC·지표 범례 */
|
|
const hoverBar = legend ?? lastKnownBar;
|
|
/** 종목명 옆·우측 상단 시세 — 실시간 티커/최신 봉 (크로스헤어 무관) */
|
|
const quoteBar = lastKnownBar;
|
|
const liveQuote = useMemo(
|
|
() => resolveChartLiveQuote(chartTicker, quoteBar, bars),
|
|
[chartTicker, quoteBar, bars],
|
|
);
|
|
|
|
const goToMarketChartBridge = useCallback((market: string) => {
|
|
goToMarketChart(market);
|
|
}, [goToMarketChart]);
|
|
|
|
const bridge = useMemo<ChartWorkspaceBridge>(() => ({
|
|
goToMarket: goToMarketChartBridge,
|
|
getSymbol: () => (layoutDef.count > 1 ? activeSlotSymbol : symbol),
|
|
getIndicators: () => (layoutDef.count > 1 ? activeSlotIndicators : indicators),
|
|
}), [goToMarketChartBridge, layoutDef.count, activeSlotSymbol, symbol, activeSlotIndicators, indicators]);
|
|
|
|
const settingsPageProps = useMemo<SettingsPageChartBindings>(() => ({
|
|
magnetMode,
|
|
onMagnetMode: setMagnetMode,
|
|
liveStrategies,
|
|
onClearLiveMarkers: () => { clearLiveMarkers(); managerRef.current?.clearLiveStrategyMarkers(); },
|
|
liveSettings: liveStrategySettings,
|
|
onLiveSettingsChange: handleLiveSettingsChange,
|
|
watchlistCount: isVirtualActive ? monitoredMarkets.length : watchlist.length,
|
|
monitoredMarkets,
|
|
virtualDriven: isVirtualActive,
|
|
chartIndicators: bulkSettingsIndicators,
|
|
onAddIndicatorToChart: handleAddIndicator,
|
|
onRemoveIndicatorFromChart: handleRemoveByType,
|
|
onIndicatorListOrderChange: handleIndicatorListOrderChange,
|
|
chartCandleAreaPriceLabels,
|
|
onChartCandleAreaPriceLabels: v => {
|
|
saveAppDef({ chartCandleAreaPriceLabels: v });
|
|
managerRef.current?.setCandleAreaPriceLabelsEnabled(v);
|
|
slotRefs.current.forEach(slot => slot?.setCandleAreaPriceLabelsEnabled(v));
|
|
},
|
|
chartSeriesPriceLabels,
|
|
onChartSeriesPriceLabels: v => {
|
|
saveAppDef({ chartSeriesPriceLabels: v });
|
|
managerRef.current?.setIndicatorAreaPriceLabelsEnabled(v);
|
|
slotRefs.current.forEach(slot => slot?.setIndicatorAreaPriceLabelsEnabled(v));
|
|
},
|
|
chartVolumeVisible,
|
|
onChartVolumeVisible: v => {
|
|
saveAppDef({ chartVolumeVisible: v });
|
|
const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null;
|
|
managerRef.current?.setVolumeVisible(v, wrapper?.clientHeight);
|
|
slotRefs.current.forEach(slot => slot?.setVolumeVisible(v));
|
|
},
|
|
chartLiveReceiveHighlight,
|
|
onChartLiveReceiveHighlight: v => saveAppDef({ chartLiveReceiveHighlight: v }),
|
|
chartLegendOptions,
|
|
onChartLegendOptionsChange: patch => {
|
|
saveAppDef({
|
|
chartLegendOptions: { ...chartLegendOptions, ...patch } as unknown as Record<string, boolean>,
|
|
});
|
|
},
|
|
chartCrosshairInfoVisible,
|
|
onChartCrosshairInfoVisible: v => saveAppDef({ chartCrosshairInfoVisible: v }),
|
|
chartHoverToolbarVisible,
|
|
onChartHoverToolbarVisible: v => saveAppDef({ chartHoverToolbarVisible: v }),
|
|
chartPaneSeparator,
|
|
onChartPaneSeparatorChange: opts => {
|
|
saveAppDef({ chartPaneSeparator: opts });
|
|
managerRef.current?.setPaneSeparatorOptions(opts);
|
|
managerRef.current?.refreshPaneSeparatorOverlay();
|
|
slotRefs.current.forEach(slot => slot?.setPaneSeparatorOptions(opts));
|
|
},
|
|
onPaperAccountReset: async () => {
|
|
const { resetPaperAccount } = await import('../utils/backendApi');
|
|
await resetPaperAccount();
|
|
await refreshPaperAccount();
|
|
},
|
|
onDisplayTimezoneChange: handleTimezoneChange,
|
|
onChartTimeFormatChange: handleChartTimeFormatChange,
|
|
onTradeAlertTimeFormatChange: handleTradeAlertTimeFormatChange,
|
|
displayTimezone,
|
|
chartTimeFormat,
|
|
tradeAlertTimeFormat,
|
|
}), [
|
|
magnetMode, liveStrategies, liveStrategySettings, handleLiveSettingsChange,
|
|
isVirtualActive, monitoredMarkets, watchlist, bulkSettingsIndicators,
|
|
handleAddIndicator, handleRemoveByType, handleIndicatorListOrderChange,
|
|
chartCandleAreaPriceLabels, chartSeriesPriceLabels, chartVolumeVisible,
|
|
chartLiveReceiveHighlight, chartLegendOptions, chartCrosshairInfoVisible,
|
|
chartHoverToolbarVisible, chartPaneSeparator, saveAppDef, clearLiveMarkers,
|
|
refreshPaperAccount, handleTimezoneChange, handleChartTimeFormatChange,
|
|
handleTradeAlertTimeFormatChange, displayTimezone, chartTimeFormat, tradeAlertTimeFormat,
|
|
]);
|
|
|
|
const contextValue = useMemo<ChartWorkspaceSharedState>(() => ({
|
|
symbol,
|
|
indicators,
|
|
goToMarketChart,
|
|
refreshPaperAccount,
|
|
paperCashBalance,
|
|
paperCoinQty,
|
|
handlePaperOrderFilled,
|
|
paperRefreshKey,
|
|
mode,
|
|
priorityMarkets,
|
|
marketPanelOpen: showMarketPanel,
|
|
marketTickers,
|
|
marketInfos,
|
|
marketLoading,
|
|
usdRate,
|
|
settingsPageProps,
|
|
}), [
|
|
symbol, indicators, goToMarketChart, refreshPaperAccount,
|
|
paperCashBalance, paperCoinQty, handlePaperOrderFilled, paperRefreshKey, mode,
|
|
priorityMarkets, showMarketPanel, marketTickers, marketInfos, marketLoading, usdRate,
|
|
settingsPageProps,
|
|
]);
|
|
|
|
const allSlotsPaintReady = useMemo(() => {
|
|
void slotPaintEpoch;
|
|
for (let i = 0; i < layoutDef.count; i++) {
|
|
if (!slotPaintReadyRef.current[i]) return false;
|
|
}
|
|
return layoutDef.count > 0;
|
|
}, [layoutDef.count, slotPaintEpoch]);
|
|
|
|
const chartDataFetched = !useUpbit || (!isLoading && bars.length >= 2 && barsMarket === symbol);
|
|
const chartPaintComplete = layoutDef.count > 1 ? allSlotsPaintReady : singleChartPaintReady;
|
|
const chartDataLoading = useUpbit && chartVisible && (!chartConfigReady || !chartDataFetched);
|
|
/** paint 완료 = reloadAll setData까지 끝남 — chartDataFetched 와 이중 게이트 시 영구 로딩 가능 */
|
|
const chartAreaLoading = useUpbit && chartVisible && (
|
|
!chartConfigReady || !chartPaintComplete
|
|
);
|
|
|
|
const viewProps: ChartWorkspaceViewProps = {
|
|
onOpenNotificationsPage,
|
|
symbol, timeframe, chartType, theme, mode, logScale, showGrid,
|
|
toolbarActiveIndicators, layoutDef, syncOptions, activeSlot,
|
|
activeSlotSymbol, activeSlotTf, slot0InitProps, slotInitProps,
|
|
syncTime, syncVisibleRange, syncLogicalRange, slotRefs, managerRef,
|
|
undoStack, redoStack, canUndo, canRedo, drawingTool, drawings,
|
|
drawingsLocked, drawingsVisible, indicatorsVisible, magnetMode,
|
|
snapToIndicator, keepLockedOnDelete, legend, showStats, showWatch,
|
|
showAlert, showObjectTree, percentScale, magnifierActive, showMarketPanel,
|
|
mobileRightOpen, mobileRightTab, marketTickers, marketInfos, marketLoading, usdRate,
|
|
refreshAllTickers, setMarketSearchOpen,
|
|
formalLogin, requireFormalLogin, chartVisible, menuPage,
|
|
isMobile, indicators, effectiveIndicators, mainChartStyle,
|
|
showMainChartModal, showBulkIndSettings, settingsModalId, ctxToolbar,
|
|
drawingCtxToolbar, drawingSettingsId, selectedDrawingId,
|
|
focusedIndicatorId, overlayVisibility, overlayVisibilityRef,
|
|
candleOverlayToggles, customOverlaySelection, custom1OverlaySelection,
|
|
activeCustomOverlaySlot, customOverlaySettingsSlot, customOverlayActive,
|
|
custom1OverlayActive, chartCandleAreaPriceLabels, chartSeriesPriceLabels,
|
|
chartVolumeVisible, chartPriceExtremeLabelsVisible, chartPaneSeparator, chartCrosshairInfoVisible,
|
|
chartHoverToolbarVisible, chartLiveReceiveHighlight,
|
|
chartRealtimeSource: (chartRealtimeSource ?? 'BACKEND_STOMP') as 'BACKEND_STOMP' | 'UPBIT_DIRECT',
|
|
displayTimezone, chartTimeFormat, chartLegendOptions, useUpbit,
|
|
wsStatus: (wsStatus ?? 'disconnected') as import('../hooks/useChartRealtimeData').WsStatus,
|
|
bars, barsMarket: barsMarket ?? symbol, barsMarketRef, chartMountKey, chartLiveReadyRef,
|
|
pendingRealtimeBarRef, isSingleLoadingMore, singleLoadMoreRef,
|
|
btMatchesChart, btRunning, btSettings, btStats, btSignals, btClear,
|
|
btMarkerPayloadRef, syncBacktestMarkersRef, showBtSettings, showBtResult,
|
|
showLivePanel, liveStrategies, liveStrategySettings, monitoredMarkets,
|
|
isVirtualActive, watchlist, chartBtReportModel, chartSymbol, chartScreenActive,
|
|
orderbook, obWsStatus, spread, orderbookPrevClose, chartTicker, tradeFill,
|
|
paperCashBalance, paperCoinQty, paperTradingEnabled, paperAutoTradeEnabled,
|
|
appDefaults, stats, priceMap, alerts, hoverBar, quoteBar, liveQuote,
|
|
isLoading, error: error ?? null, bulkSettingsIndicators, chartIndicatorsForSettings,
|
|
chartConfigReady, chartAreaLoading, chartDataLoading,
|
|
getParams, getVisualConfig, activeSlotIndicators,
|
|
setSymbol, setLegend, setChartType, setMode, setLogScale, setShowGrid,
|
|
setDrawingTool, setDrawings, setDrawingsLocked, setDrawingsVisible,
|
|
setIndicatorsVisible, setMagnetMode, setSnapToIndicator, setKeepLockedOnDelete,
|
|
setShowStats, setShowWatch, setShowAlert, setShowObjectTree, setMagnifierActive,
|
|
setShowMarketPanel, setMobileRightOpen, setMobileRightTab, setSyncTime,
|
|
setSyncVisibleRange, setSyncLogicalRange, setActiveSlotSymbol, setCanUndo,
|
|
setCanRedo, setCtxToolbar, setDrawingCtxToolbar, setDrawingSettingsId,
|
|
setSelectedDrawingId, setShowMainChartModal, setShowBulkIndSettings,
|
|
setSettingsModalId, setCustomOverlaySettingsSlot, setShowBtSettings,
|
|
setShowBtResult, setShowLivePanel, setWatchlist, setAlerts, setIndicators,
|
|
setSyncOptions, setPercentScale, setBtStrategyName, setBtReportCreatedAt, setBtSettings,
|
|
saveAppDef, applyChartIndicators,
|
|
onThemeToggle, handleTimeframe, handleLayoutSelect, handleSetActiveSlot,
|
|
handleActiveSlotIndicatorsChange, handleActiveSlotSymbolChange,
|
|
handleVisibleRangeChange, handleLogicalRangeChange, handleSlotFullView,
|
|
applyTradeFill, handleOrderbookPick, handleTradeMarketSelect, pushDrawing,
|
|
handleUndo, handleRedo, handleHoverPaneLegend, handleLeavePaneLegend,
|
|
clearHoverTimer, handleDrawingClick, handleDrawingDoubleClick,
|
|
handleMainChartSave, handleAddIndicator, handleAddIndicators,
|
|
handleRemoveByType, handleIndicatorListOrderChange, handleOpenIndicatorSettings,
|
|
handleIndicatorSave, handleBulkIndicatorApply, handleToggleIndicatorHidden,
|
|
handleDuplicateIndicator, handleRemoveIndicatorById, handleScreenshot,
|
|
handleReorderIndicators, handleMergeIndicators, handleSplitIndicatorPane,
|
|
handleExpandIndicator, handleRestoreIndicators, handleToggleCandleOverlay,
|
|
handleTogglePriceExtremeLabels,
|
|
handleCustomOverlayActiveChange, handleCustom1OverlayActiveChange,
|
|
handleSaveCustomOverlaySelection, syncCustomOverlaysToManager,
|
|
handleCandlesReady, handleSingleChartPaintReadyChange, handleSlotPaintReadyChange,
|
|
applyBacktestMarkersWhenReady, btRun, refreshPaperAccount,
|
|
handlePaperOrderFilled, handleLiveSettingsChange, clearLiveMarkers,
|
|
goToMarketChart, handleTimezoneChange,
|
|
};
|
|
|
|
const liveNotifierProps = {
|
|
markets: monitoredMarkets,
|
|
marketSubscriptions: liveStompSubscriptions,
|
|
activeMarket: symbol,
|
|
enabled: formalLogin && monitoredMarkets.length > 0 && appSettingsLoaded,
|
|
popupEnabled: appDefaults.tradeAlertPopup ?? true,
|
|
chartMarkersActive: chartVisible,
|
|
onMarkersChange: (markers: LiveMarker[]) => {
|
|
liveMarkersRef.current = markers;
|
|
managerRef.current?.setLiveStrategyMarkers(markers, appDefaults.btShowPrice);
|
|
},
|
|
};
|
|
|
|
return {
|
|
bridge,
|
|
contextValue,
|
|
viewProps,
|
|
liveNotifierProps,
|
|
liveNotifierRef,
|
|
};
|
|
}
|