frontend 속도 개선

This commit is contained in:
Macbook
2026-06-11 00:14:14 +09:00
parent 0ff1992b64
commit 54fdc3e77a
15 changed files with 522 additions and 71 deletions
+184
View File
@@ -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",
+1
View File
@@ -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"
}
+112 -46
View File
@@ -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<MenuPage> = 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({
</Suspense>
)}
{chartScreen}
{chartSlot}
<AppNotificationLayer
menuPage={menuPage}
@@ -451,6 +474,48 @@ function App() {
}, []);
const [appDownloadOpen, setAppDownloadOpen] = useState(false);
const { defaults: appDefaults, isLoaded: appSettingsLoaded, save: saveAppDef } = useAppSettings(sessionKey);
const { permissions: menuPermissions, isLoaded: menuPermsLoaded, can: canMenu } = useMenuPermissions(sessionKey);
const [theme, setTheme] = useState<Theme>(loadLocalTheme);
const [menuPage, setMenuPage] = useState<MenuPage>(loadLastMenu);
const [settingsInitialCategory, setSettingsInitialCategory] = useState<SettingsCategoryId | undefined>();
const [verificationFocusIssueId, setVerificationFocusIssueId] = useState<number | null>(null);
// 차트 lazy mount 관련 상태: 처음 chart 메뉴 진입 시 마운트, 이후 유지
const [chartEverActivated, setChartEverActivated] = useState(() => loadLastMenu() === 'chart');
const [pendingChartMarket, setPendingChartMarket] = useState<string | null>(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<Theme>(loadLocalTheme);
const [menuPage, setMenuPage] = useState<MenuPage>('chart');
const [settingsInitialCategory, setSettingsInitialCategory] = useState<SettingsCategoryId | undefined>();
const [verificationFocusIssueId, setVerificationFocusIssueId] = useState<number | null>(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 ? (
<Suspense fallback={null}>
<ChartWorkspaceRoot
visible={menuPage === 'chart'}
pendingChartMarket={pendingChartMarket}
onPendingChartMarketConsumed={() => 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)}
/>
</Suspense>
) : null;
return (
<TradeNotificationProvider
popupEnabled={appDefaults.tradeAlertPopup ?? true}
soundEnabled={appDefaults.tradeAlertSoundEnabled ?? true}
soundId={appDefaults.tradeAlertSound ?? 'bell'}
settingsSessionKey={authUser?.userId ?? 0}
>
<ChartWorkspaceRoot
visible={menuPage === 'chart'}
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)}
>
<AppMainContent
theme={theme}
@@ -620,7 +685,7 @@ function App() {
appDownloadOpen={appDownloadOpen}
setAppDownloadOpen={setAppDownloadOpen}
menuPage={menuPage}
setMenuPage={setMenuPage}
setMenuPage={setMenuPagePersist}
guardedSetMenuPage={guardedSetMenuPage}
settingsInitialCategory={settingsInitialCategory}
setSettingsInitialCategory={setSettingsInitialCategory}
@@ -639,8 +704,9 @@ function App() {
indicatorSettingsRevision={indicatorSettingsRevision}
handleLoginSuccess={handleLoginSuccess}
handleLogout={handleLogout}
goToMarketChart={handleGoToMarketChart}
chartSlot={chartSlot}
/>
</ChartWorkspaceRoot>
</TradeNotificationProvider>
);
}
+63 -10
View File
@@ -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<ChartWorkspaceContextValue | null>(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<ChartWorkspaceContextValue>(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);
}
+24 -10
View File
@@ -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<ChartWorkspaceBridge, ChartWorkspaceRootProps>(
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<ChartWorkspaceBridge, ChartWorkspac
useImperativeHandle(ref, () => 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: (
<div style={{ display: visible ? 'contents' : 'none' }}>
<ChartWorkspaceView {...viewProps} />
</div>
),
}), [contextValue, visible, viewProps]);
}), [contextValue]);
return (
<ChartWorkspaceProvider value={providerValue}>
<LiveSignalNotifier ref={liveNotifierRef} {...liveNotifierProps} />
{children}
<div style={{ display: visible ? 'contents' : 'none' }}>
<ChartWorkspaceView {...viewProps} />
</div>
</ChartWorkspaceProvider>
);
},
@@ -1,3 +1,4 @@
import '../styles/tradeRightPanel.css';
import React from 'react';
import DrawingToolbar from '../components/DrawingToolbar';
import BottomBar from '../components/BottomBar';
+3 -1
View File
@@ -1175,6 +1175,8 @@ export function useChartWorkspace({
// ── 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');
@@ -1183,7 +1185,7 @@ export function useChartWorkspace({
}
}
return map;
}, [watchlist]);
}, [watchlist, showMarketPanel, tickerFeedActive]);
// ── DB 워크스페이스 동기화 ─────────────────────────────────────────────
+1
View File
@@ -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';
@@ -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,
@@ -1,6 +1,7 @@
/**
* 모의투자 대시보드 — 가상매매와 동일 3열 레이아웃(좌·중앙·우 접기/리사이즈)
*/
import '../styles/paperDashboard.css';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
loadPaperSummary,
+1
View File
@@ -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}
>
<span className="tmb-nav-icon">{icon}</span>
<span className="tmb-nav-label">{label}</span>
+8 -2
View File
@@ -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]);
// ── 파라미터 ────────────────────────────────────────────────────────────
+12 -1
View File
@@ -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';
},
},
},