From 54fdc3e77a888ef0ec10067bfdda48e82452a702 Mon Sep 17 00:00:00 2001 From: Macbook Date: Thu, 11 Jun 2026 00:14:14 +0900 Subject: [PATCH] =?UTF-8?q?frontend=20=EC=86=8D=EB=8F=84=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package-lock.json | 184 ++++++++++++++++++ frontend/package.json | 1 + frontend/src/App.tsx | 158 ++++++++++----- frontend/src/chart/ChartWorkspaceContext.tsx | 73 ++++++- frontend/src/chart/ChartWorkspaceRoot.tsx | 34 +++- frontend/src/chart/ChartWorkspaceView.tsx | 1 + frontend/src/chart/useChartWorkspace.ts | 4 +- frontend/src/components/AppPopup.tsx | 1 + .../src/components/BacktestHistoryPage.tsx | 1 + frontend/src/components/PaperTradingPage.tsx | 1 + frontend/src/components/TopMenuBar.tsx | 1 + frontend/src/hooks/useIndicatorSettings.ts | 10 +- frontend/vite.config.ts | 13 +- package-lock.json | 109 +++++++++++ package.json | 2 +- 15 files changed, 522 insertions(+), 71 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 81e2e07..1d04d78 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,6 +25,7 @@ "@types/react-dom": "^18.3.1", "@types/sockjs-client": "^1.5.4", "@vitejs/plugin-react": "^4.3.1", + "rollup-plugin-visualizer": "^5.14.0", "typescript": "^5.5.4", "vite": "^5.4.0" } @@ -1672,6 +1673,16 @@ "node": ">=0.10.0" } }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/dijkstrajs": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", @@ -1826,6 +1837,22 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1835,6 +1862,19 @@ "node": ">=8" } }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1962,6 +2002,24 @@ "lightweight-charts": "^5.0.0" } }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -2014,6 +2072,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", @@ -2176,6 +2247,109 @@ "fsevents": "~2.3.2" } }, + "node_modules/rollup-plugin-visualizer": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-5.14.0.tgz", + "integrity": "sha512-VlDXneTDaKsHIw8yzJAFWtrzguoJ/LnQ+lMpoVfYJ3jJF4Ihe5oYLAqLklIK/35lgUY+1yEzCkHyZ1j4A5w5fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "open": "^8.4.0", + "picomatch": "^4.0.2", + "source-map": "^0.7.4", + "yargs": "^17.5.1" + }, + "bin": { + "rollup-plugin-visualizer": "dist/bin/cli.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "rolldown": "1.x", + "rollup": "2.x || 3.x || 4.x" + }, + "peerDependenciesMeta": { + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2249,6 +2423,16 @@ "ms": "^2.1.1" } }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5f4140c..102b2e8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "@types/react-dom": "^18.3.1", "@types/sockjs-client": "^1.5.4", "@vitejs/plugin-react": "^4.3.1", + "rollup-plugin-visualizer": "^5.14.0", "typescript": "^5.5.4", "vite": "^5.4.0" } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 597de4f..3712ad8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -34,14 +34,33 @@ import { clearAdminUnlock } from './utils/adminUnlock'; import type { LoginResponse } from './utils/backendApi'; import { invalidateAppSettingsCache } from './hooks/useAppSettings'; import { invalidateIndicatorSettingsCache } from './hooks/useIndicatorSettings'; -import { ChartWorkspaceRoot } from './chart/ChartWorkspaceRoot'; + +const LAST_MENU_KEY = 'gc_last_menu'; +const CHART_ONLY_INITIAL: ReadonlySet = new Set(['dashboard', 'settings', 'verification-board']); + +function loadLastMenu(): MenuPage { + try { + const saved = localStorage.getItem(LAST_MENU_KEY) as MenuPage | null; + // 가상매매·투자관리 등 로그인이 필요한 메뉴는 첫 로드 시 복원하지 않음 + if (saved && saved !== 'chart') return saved; + } catch { /* localStorage 불가 */ } + return 'chart'; +} + +function saveLastMenu(page: MenuPage): void { + try { localStorage.setItem(LAST_MENU_KEY, page); } catch { /* no-op */ } +} import { useChartWorkspaceContext } from './chart/ChartWorkspaceContext'; + +const ChartWorkspaceRoot = lazy(() => + import('./chart/ChartWorkspaceRoot').then(m => ({ default: m.ChartWorkspaceRoot })) +); import './App.css'; -import './styles/appPopup.css'; -import './styles/paperDashboard.css'; -import './styles/tradeRightPanel.css'; -import './styles/virtualTradingDashboard.css'; -import './styles/backtestDashboard.css'; +// virtualTradingDashboard.css는 VirtualTradingPage.tsx에서 import +// paperDashboard.css는 PaperTradingPage.tsx에서 import +// tradeRightPanel.css는 ChartWorkspaceView.tsx에서 import +// backtestDashboard.css는 BacktestHistoryPage.tsx, AnalysisReportPage.tsx에서 import +// appPopup.css는 AppPopup.tsx에서 import const DashboardPage = lazy(() => import('./components/DashboardPage')); const StrategyPage = lazy(() => import('./components/StrategyPage')); @@ -91,6 +110,8 @@ function AppMainContent({ indicatorSettingsRevision, handleLoginSuccess, handleLogout, + goToMarketChart, + chartSlot, }: { theme: Theme; onThemeToggle: () => void; @@ -123,19 +144,21 @@ function AppMainContent({ indicatorSettingsRevision: number; handleLoginSuccess: (res: LoginResponse) => void; handleLogout: () => void; + /** 차트로 이동하는 App 레벨 콜백 */ + goToMarketChart: (market: string) => void; + /** 차트 화면 슬롯 (lazy ChartWorkspaceRoot) */ + chartSlot: React.ReactNode; }) { const isMobile = useIsMobile(); const { symbol, indicators, - goToMarketChart, refreshPaperAccount, handlePaperOrderFilled, paperRefreshKey, mode, marketTickers, settingsPageProps, - chartScreen, } = useChartWorkspaceContext(); const paperTradingEnabled = appDefaults.paperTradingEnabled ?? true; @@ -405,7 +428,7 @@ function AppMainContent({ )} - {chartScreen} + {chartSlot} (loadLocalTheme); + const [menuPage, setMenuPage] = useState(loadLastMenu); + const [settingsInitialCategory, setSettingsInitialCategory] = useState(); + const [verificationFocusIssueId, setVerificationFocusIssueId] = useState(null); + + // 차트 lazy mount 관련 상태: 처음 chart 메뉴 진입 시 마운트, 이후 유지 + const [chartEverActivated, setChartEverActivated] = useState(() => loadLastMenu() === 'chart'); + const [pendingChartMarket, setPendingChartMarket] = useState(null); + + // menuPage 변경 시 localStorage 저장 + chart 활성화 추적 + const setMenuPagePersist = useCallback((page: MenuPage) => { + saveLastMenu(page); + setMenuPage(page); + if (page === 'chart') setChartEverActivated(true); + }, []); + + // 외부(대시보드·알림 등)에서 특정 마켓으로 차트 이동 요청 + const handleGoToMarketChart = useCallback((market: string) => { + setPendingChartMarket(market); + setMenuPagePersist('chart'); + setChartEverActivated(true); + }, [setMenuPagePersist]); + + useEffect(() => { + if (menuPage === 'chart') setChartEverActivated(true); + }, [menuPage]); + + // 지표 설정: 차트·전략·알림 관련 페이지 첫 진입 시 로드 (이후 캐시 유지) + const INDICATOR_SETTINGS_PAGES: MenuPage[] = ['chart', 'strategy-editor', 'notifications', 'strategy', 'backtest', 'analysis-report', 'settings']; + const [indicatorSettingsEnabled, setIndicatorSettingsEnabled] = useState( + () => INDICATOR_SETTINGS_PAGES.includes(menuPage), + ); + useEffect(() => { + if (!indicatorSettingsEnabled && INDICATOR_SETTINGS_PAGES.includes(menuPage)) { + setIndicatorSettingsEnabled(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [menuPage, indicatorSettingsEnabled]); + const { getParams: getIndicatorParams, saveParams: saveIndicatorParams, @@ -458,20 +523,12 @@ function App() { saveVisual: saveIndicatorVisual, saveAllIndicatorDefaults, settingsRevision: indicatorSettingsRevision, - } = useIndicatorSettings(sessionKey); + } = useIndicatorSettings(sessionKey, indicatorSettingsEnabled); const getParams = getIndicatorParams; const saveParams = saveIndicatorParams; const getVisualConfig = getIndicatorVisual; const saveVisual = saveIndicatorVisual; - const { defaults: appDefaults, isLoaded: appSettingsLoaded, save: saveAppDef } = useAppSettings(sessionKey); - const { permissions: menuPermissions, isLoaded: menuPermsLoaded, can: canMenu } = useMenuPermissions(sessionKey); - - const [theme, setTheme] = useState(loadLocalTheme); - const [menuPage, setMenuPage] = useState('chart'); - const [settingsInitialCategory, setSettingsInitialCategory] = useState(); - const [verificationFocusIssueId, setVerificationFocusIssueId] = useState(null); - useEffect(() => { if (menuPage !== 'settings') setSettingsInitialCategory(undefined); }, [menuPage]); @@ -532,22 +589,22 @@ function App() { } if (page === 'notifications' && !canMenu('notifications')) return; if (page !== 'notifications' && !canMenu(page)) { - setMenuPage(firstAllowedTopMenu(menuPermissions)); + setMenuPagePersist(firstAllowedTopMenu(menuPermissions)); return; } - setMenuPage(page); - }, [canMenu, menuPermissions, formalLogin, requireFormalLogin]); + setMenuPagePersist(page); + }, [canMenu, menuPermissions, formalLogin, requireFormalLogin, setMenuPagePersist]); useEffect(() => { if (!menuPermsLoaded) return; if (menuPage === 'notifications') { - if (!canMenu('notifications')) setMenuPage(firstAllowedTopMenu(menuPermissions)); + if (!canMenu('notifications')) setMenuPagePersist(firstAllowedTopMenu(menuPermissions)); return; } if (!canMenu(menuPage)) { - setMenuPage(firstAllowedTopMenu(menuPermissions)); + setMenuPagePersist(firstAllowedTopMenu(menuPermissions)); } - }, [menuPage, menuPermsLoaded, menuPermissions, canMenu]); + }, [menuPage, menuPermsLoaded, menuPermissions, canMenu, setMenuPagePersist]); const handleThemeToggle = useCallback(() => { setTheme(t => { @@ -580,32 +637,40 @@ function App() { ); } + // 차트 슬롯: 처음 chart 페이지 진입 시 마운트, 이후 visible 토글 + const chartSlot = chartEverActivated ? ( + + setPendingChartMarket(null)} + menuPage={menuPage} + sessionKey={sessionKey} + formalLogin={formalLogin} + requireFormalLogin={requireFormalLogin} + theme={theme} + onThemeToggle={handleThemeToggle} + appDefaults={appDefaults as unknown as import('./utils/backendApi').AppSettingsDto} + appSettingsLoaded={appSettingsLoaded} + saveAppDef={saveAppDef} + getParams={getParams} + saveParams={saveParams} + getVisualConfig={getVisualConfig} + saveVisual={saveVisual} + saveAllIndicatorDefaults={saveAllIndicatorDefaults} + indicatorSettingsRevision={indicatorSettingsRevision} + onOpenNotificationsPage={openNotificationsPage} + onOpenAppDownload={() => setAppDownloadOpen(true)} + /> + + ) : null; + return ( - setAppDownloadOpen(true)} > - ); } diff --git a/frontend/src/chart/ChartWorkspaceContext.tsx b/frontend/src/chart/ChartWorkspaceContext.tsx index 9924b09..9aa8486 100644 --- a/frontend/src/chart/ChartWorkspaceContext.tsx +++ b/frontend/src/chart/ChartWorkspaceContext.tsx @@ -2,6 +2,9 @@ import React, { createContext, useContext } from 'react'; import type { ChartMode, IndicatorConfig } from '../types'; import type { TickerData } from '../hooks/useMarketTicker'; import type { SettingsPageChartBindings } from './useChartWorkspace'; +import { DEFAULT_DISPLAY_TIMEZONE } from '../utils/timezone'; +import { DEFAULT_CHART_TIME_FORMAT } from '../utils/chartTimeFormat'; +import { DEFAULT_TRADE_ALERT_TIME_FORMAT } from '../utils/tradeAlertTimeFormat'; export interface ChartWorkspaceSharedState { symbol: string; @@ -21,12 +24,66 @@ export interface ChartWorkspaceSharedState { settingsPageProps: SettingsPageChartBindings; } -export interface ChartWorkspaceContextValue extends ChartWorkspaceSharedState { - /** 실시간 차트 화면 — `.app` flex 레이아웃 안에서 렌더해야 좌·우 패널·메뉴가 정상 표시됨 */ - chartScreen: React.ReactNode; -} +export type ChartWorkspaceContextValue = ChartWorkspaceSharedState; -const ChartWorkspaceContext = createContext(null); +const NULL_SETTINGS_PAGE_PROPS: SettingsPageChartBindings = { + magnetMode: 'off', + onMagnetMode: () => {}, + liveStrategies: [], + onClearLiveMarkers: () => {}, + liveSettings: { market: '', strategyId: null, isLiveCheck: false, executionType: 'CANDLE_CLOSE', positionMode: 'LONG_ONLY' }, + onLiveSettingsChange: () => {}, + watchlistCount: 0, + monitoredMarkets: [], + virtualDriven: false, + chartIndicators: [], + onAddIndicatorToChart: () => {}, + onRemoveIndicatorFromChart: () => {}, + onIndicatorListOrderChange: () => {}, + chartCandleAreaPriceLabels: false, + onChartCandleAreaPriceLabels: () => {}, + chartSeriesPriceLabels: false, + onChartSeriesPriceLabels: () => {}, + chartVolumeVisible: true, + onChartVolumeVisible: () => {}, + chartLiveReceiveHighlight: true, + onChartLiveReceiveHighlight: () => {}, + chartLegendOptions: undefined, + onChartLegendOptionsChange: () => {}, + chartCrosshairInfoVisible: true, + onChartCrosshairInfoVisible: () => {}, + chartHoverToolbarVisible: true, + onChartHoverToolbarVisible: () => {}, + chartPaneSeparator: undefined, + onChartPaneSeparatorChange: () => {}, + onPaperAccountReset: async () => {}, + onDisplayTimezoneChange: () => {}, + onChartTimeFormatChange: () => {}, + onTradeAlertTimeFormatChange: () => {}, + displayTimezone: DEFAULT_DISPLAY_TIMEZONE, + chartTimeFormat: DEFAULT_CHART_TIME_FORMAT, + tradeAlertTimeFormat: DEFAULT_TRADE_ALERT_TIME_FORMAT, +}; + +export const NULL_CHART_WORKSPACE_CONTEXT: ChartWorkspaceContextValue = { + symbol: 'KRW-BTC', + indicators: [], + goToMarketChart: () => {}, + refreshPaperAccount: async () => {}, + paperCashBalance: 0, + paperCoinQty: 0, + handlePaperOrderFilled: () => {}, + paperRefreshKey: 0, + mode: 'chart', + priorityMarkets: [], + marketPanelOpen: false, + marketTickers: new Map(), + marketLoading: false, + usdRate: 0, + settingsPageProps: NULL_SETTINGS_PAGE_PROPS, +}; + +const ChartWorkspaceContext = createContext(NULL_CHART_WORKSPACE_CONTEXT); export function ChartWorkspaceProvider({ value, @@ -43,9 +100,5 @@ export function ChartWorkspaceProvider({ } export function useChartWorkspaceContext(): ChartWorkspaceContextValue { - const ctx = useContext(ChartWorkspaceContext); - if (!ctx) { - throw new Error('useChartWorkspaceContext must be used within ChartWorkspaceProvider'); - } - return ctx; + return useContext(ChartWorkspaceContext); } diff --git a/frontend/src/chart/ChartWorkspaceRoot.tsx b/frontend/src/chart/ChartWorkspaceRoot.tsx index a7194ce..48912b6 100644 --- a/frontend/src/chart/ChartWorkspaceRoot.tsx +++ b/frontend/src/chart/ChartWorkspaceRoot.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useImperativeHandle, useMemo } from 'react'; +import React, { forwardRef, useImperativeHandle, useMemo, useEffect, useRef } from 'react'; import { LiveSignalNotifier } from '../components/LiveSignalNotifier'; import { ChartWorkspaceProvider } from './ChartWorkspaceContext'; import { ChartWorkspaceView } from './ChartWorkspaceView'; @@ -7,12 +7,14 @@ import type { ChartWorkspaceBridge } from './chartWorkspaceBridge'; export interface ChartWorkspaceRootProps extends UseChartWorkspaceParams { visible: boolean; - children: React.ReactNode; + /** 외부에서 이동 요청된 마켓 (알림·대시보드 등에서 차트로 이동 시) */ + pendingChartMarket?: string | null; + onPendingChartMarketConsumed?: () => void; } export const ChartWorkspaceRoot = forwardRef( function ChartWorkspaceRoot(props, ref) { - const { visible, children, ...workspaceParams } = props; + const { visible, pendingChartMarket, onPendingChartMarketConsumed, ...workspaceParams } = props; const { bridge, contextValue, @@ -23,19 +25,31 @@ export const ChartWorkspaceRoot = forwardRef bridge, [bridge]); + // 외부에서 마켓 이동 요청 처리 + const pendingRef = useRef(pendingChartMarket); + pendingRef.current = pendingChartMarket; + const consumedRef = useRef(onPendingChartMarketConsumed); + consumedRef.current = onPendingChartMarketConsumed; + + useEffect(() => { + if (pendingChartMarket) { + bridge.goToMarket(pendingChartMarket); + consumedRef.current?.(); + } + // bridge.goToMarket is stable (useCallback), pendingChartMarket is the trigger + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pendingChartMarket]); + const providerValue = useMemo(() => ({ ...contextValue, - chartScreen: ( -
- -
- ), - }), [contextValue, visible, viewProps]); + }), [contextValue]); return ( - {children} +
+ +
); }, diff --git a/frontend/src/chart/ChartWorkspaceView.tsx b/frontend/src/chart/ChartWorkspaceView.tsx index 0bce5ce..4a5ea60 100644 --- a/frontend/src/chart/ChartWorkspaceView.tsx +++ b/frontend/src/chart/ChartWorkspaceView.tsx @@ -1,3 +1,4 @@ +import '../styles/tradeRightPanel.css'; import React from 'react'; import DrawingToolbar from '../components/DrawingToolbar'; import BottomBar from '../components/BottomBar'; diff --git a/frontend/src/chart/useChartWorkspace.ts b/frontend/src/chart/useChartWorkspace.ts index 39421bf..4c7cf9a 100644 --- a/frontend/src/chart/useChartWorkspace.ts +++ b/frontend/src/chart/useChartWorkspace.ts @@ -1175,6 +1175,8 @@ export function useChartWorkspace({ // ── Watchlist 가격 맵 ────────────────────────────────────────────────── const priceMap = useMemo(() => { + // 시세 패널이 열려 있고 ticker가 활성 상태일 때만 계산 (데모 심볼용) + if (!showMarketPanel || !tickerFeedActive) return {} as Record; const map: Record = {}; for (const sym of watchlist) { const b = isUpbitMarket(sym) ? [] : generateBars(sym, '1D'); @@ -1183,7 +1185,7 @@ export function useChartWorkspace({ } } return map; - }, [watchlist]); + }, [watchlist, showMarketPanel, tickerFeedActive]); // ── DB 워크스페이스 동기화 ───────────────────────────────────────────── diff --git a/frontend/src/components/AppPopup.tsx b/frontend/src/components/AppPopup.tsx index a87fd94..6063506 100644 --- a/frontend/src/components/AppPopup.tsx +++ b/frontend/src/components/AppPopup.tsx @@ -2,6 +2,7 @@ * AppPopup — TradeAlertModal(tam-) 기준 공통 팝업 셸 * 신규 팝업은 이 컴포넌트를 사용하세요. */ +import '../styles/appPopup.css'; import React from 'react'; import { createPortal } from 'react-dom'; import { useDraggablePanel } from '../hooks/useDraggablePanel'; diff --git a/frontend/src/components/BacktestHistoryPage.tsx b/frontend/src/components/BacktestHistoryPage.tsx index 19cc53a..664b0d9 100644 --- a/frontend/src/components/BacktestHistoryPage.tsx +++ b/frontend/src/components/BacktestHistoryPage.tsx @@ -1,6 +1,7 @@ /** * BacktestHistoryPage — Golden Analysis Command Center (참조 UI 3열) */ +import '../styles/backtestDashboard.css'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { loadBacktestResults, diff --git a/frontend/src/components/PaperTradingPage.tsx b/frontend/src/components/PaperTradingPage.tsx index 4d06489..299c2c3 100644 --- a/frontend/src/components/PaperTradingPage.tsx +++ b/frontend/src/components/PaperTradingPage.tsx @@ -1,6 +1,7 @@ /** * 모의투자 대시보드 — 가상매매와 동일 3열 레이아웃(좌·중앙·우 접기/리사이즈) */ +import '../styles/paperDashboard.css'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { loadPaperSummary, diff --git a/frontend/src/components/TopMenuBar.tsx b/frontend/src/components/TopMenuBar.tsx index 6183152..852cad7 100644 --- a/frontend/src/components/TopMenuBar.tsx +++ b/frontend/src/components/TopMenuBar.tsx @@ -250,6 +250,7 @@ export const TopMenuBar = memo(function TopMenuBar({ type="button" className={`tmb-nav-btn ${activePage === page ? 'tmb-nav-btn--active' : ''}`} onClick={() => onPage(page)} + onMouseEnter={page === 'chart' ? () => { void import('../chart/ChartWorkspaceRoot'); } : undefined} > {icon} {label} diff --git a/frontend/src/hooks/useIndicatorSettings.ts b/frontend/src/hooks/useIndicatorSettings.ts index 89b9a94..0485060 100644 --- a/frontend/src/hooks/useIndicatorSettings.ts +++ b/frontend/src/hooks/useIndicatorSettings.ts @@ -120,7 +120,11 @@ export function invalidateIndicatorSettingsCache() { // ───────────────────────────────────────────────────────────────────────────── -export function useIndicatorSettings(sessionKey = 0) { +/** + * @param sessionKey 로그인 세션 키 (변경 시 캐시 무효화 및 재로드) + * @param enabled false 이면 DB 로드를 건너뜀 — 차트·전략편집·알림 페이지 진입 시에만 true + */ +export function useIndicatorSettings(sessionKey = 0, enabled = true) { const [settingsRevision, setSettingsRevision] = useState(0); const [settingsLoaded, setSettingsLoaded] = useState( () => _cache !== null && _visualCache !== null, @@ -130,6 +134,8 @@ export function useIndicatorSettings(sessionKey = 0) { useEffect(() => { const unsub = subscribeIndicatorSettings(() => setSettingsRevision(r => r + 1)); + if (!enabled) return unsub; + const cacheWarm = _loadedSessionKey === sessionKey && _cache !== null && _visualCache !== null; @@ -157,7 +163,7 @@ export function useIndicatorSettings(sessionKey = 0) { setSettingsRevision(r => r + 1); }); return unsub; - }, [sessionKey]); + }, [sessionKey, enabled]); // ── 파라미터 ──────────────────────────────────────────────────────────── diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 2cf294d..4be7bb6 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,5 +1,6 @@ import { defineConfig, createLogger } from 'vite'; import react from '@vitejs/plugin-react'; +import { visualizer } from 'rollup-plugin-visualizer'; // WebSocket proxy 재연결/종료 시 발생하는 ECONNRESET·EPIPE 는 정상 TCP 이벤트이므로 로그 억제 const SUPPRESS = ['ECONNRESET', 'EPIPE', 'ws proxy socket error']; @@ -12,7 +13,10 @@ logger.error = (msg, opts) => { export default defineConfig({ customLogger: logger, - plugins: [react()], + plugins: [ + react(), + visualizer({ filename: 'dist/stats.html', open: false, gzipSize: true, brotliSize: true }), + ], resolve: { dedupe: ['lightweight-charts'], }, @@ -27,6 +31,13 @@ export default defineConfig({ if (id.includes('node_modules/@xyflow')) return 'vendor-flow'; if (id.includes('node_modules/@stomp') || id.includes('node_modules/sockjs-client')) return 'vendor-stomp'; if (id.includes('node_modules/@tanstack/react-virtual')) return 'vendor-virtual'; + // 차트 워크스페이스 전용 청크 — lazy import로 분리된 코드 + if ( + id.includes('/chart/ChartWorkspaceRoot') || + id.includes('/chart/useChartWorkspace') || + id.includes('/chart/ChartWorkspaceView') || + id.includes('/utils/ChartManager') + ) return 'chart-workspace'; }, }, }, diff --git a/package-lock.json b/package-lock.json index b0ac8c8..0de6730 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,6 +62,7 @@ "@types/react-dom": "^18.3.1", "@types/sockjs-client": "^1.5.4", "@vitejs/plugin-react": "^4.3.1", + "rollup-plugin-visualizer": "^5.14.0", "typescript": "^5.5.4", "vite": "^5.4.0" } @@ -2804,6 +2805,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/plist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", @@ -3044,6 +3058,91 @@ "fsevents": "~2.3.2" } }, + "node_modules/rollup-plugin-visualizer": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-5.14.0.tgz", + "integrity": "sha512-VlDXneTDaKsHIw8yzJAFWtrzguoJ/LnQ+lMpoVfYJ3jJF4Ihe5oYLAqLklIK/35lgUY+1yEzCkHyZ1j4A5w5fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "open": "^8.4.0", + "picomatch": "^4.0.2", + "source-map": "^0.7.4", + "yargs": "^17.5.1" + }, + "bin": { + "rollup-plugin-visualizer": "dist/bin/cli.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "rolldown": "1.x", + "rollup": "2.x || 3.x || 4.x" + }, + "peerDependenciesMeta": { + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -3182,6 +3281,16 @@ "ms": "^2.1.1" } }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 2f2653b..7a266ae 100644 --- a/package.json +++ b/package.json @@ -14,4 +14,4 @@ "cap:android": "npm run cap:open:android -w @goldenchart/app", "cap:ios": "npm run cap:open:ios -w @goldenchart/app" } -} +} \ No newline at end of file