From 6936792ad40a396f7a94d8856cefc31cac14e127 Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 14 Jun 2026 01:03:51 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9C=84=EC=A0=AF=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/goldenchart/auth/MenuIds.java | 2 +- .../migration/V66__widget_dashboard_menu.sql | 6 + frontend/src/App.tsx | 34 + .../src/components/IndicatorDropdownPanel.tsx | 542 +++++++ frontend/src/components/NotifyUiBindings.tsx | 2 + frontend/src/components/Toolbar.tsx | 529 +------ frontend/src/components/TopMenuBar.tsx | 47 +- .../src/components/WidgetDashboardPage.tsx | 149 ++ .../floatingWidgets/FloatingWidgetLayer.tsx | 171 ++ .../FloatingWidgetLayoutPicker.tsx | 64 + .../floatingWidgets/FloatingWidgetWindow.tsx | 175 +++ .../components/layout/BuilderPageShell.tsx | 28 +- .../widgetDashboard/LayoutEditorModal.tsx | 361 +++++ .../widgetDashboard/WidgetDashboardShell.tsx | 316 ++++ .../widgetDashboard/WidgetPickerModal.tsx | 73 + .../widgetDashboard/WidgetSidePanel.tsx | 18 + .../widgetDashboard/WidgetSlotView.tsx | 98 ++ .../widgetDashboard/WidgetZoneView.tsx | 172 ++ frontend/src/hooks/useAppSettings.ts | 7 + frontend/src/hooks/useDraggablePanel.ts | 1 + frontend/src/hooks/useFloatingWidgetResize.ts | 124 ++ frontend/src/hooks/useUpbitRecentTrades.ts | 11 +- frontend/src/styles/floatingWidget.css | 420 +++++ frontend/src/styles/widgetDashboard.css | 1392 +++++++++++++++++ frontend/src/types/floatingWidget.ts | 80 + frontend/src/types/uiPreferences.ts | 3 + frontend/src/types/widgetDashboard.ts | 166 ++ frontend/src/utils/permissions.ts | 5 +- frontend/src/utils/uiPreferencesDb.ts | 39 + .../src/widgets/WidgetCandleChartPanel.tsx | 594 +++++++ .../src/widgets/WidgetDashboardContext.tsx | 144 ++ .../hosts/AccountBalanceWidgetHost.tsx | 52 + .../widgets/hosts/BacktestListWidgetHost.tsx | 41 + .../src/widgets/hosts/BuySellWidgetHost.tsx | 39 + .../widgets/hosts/CandleChartWidgetHost.tsx | 46 + .../widgets/hosts/DepthChartWidgetHost.tsx | 71 + .../widgets/hosts/MarketHeatmapWidgetHost.tsx | 43 + .../widgets/hosts/MarketListWidgetHost.tsx | 26 + .../widgets/hosts/MarketMoversWidgetHost.tsx | 41 + .../widgets/hosts/MarketSummaryWidgetHost.tsx | 67 + .../src/widgets/hosts/MiniChartWidgetHost.tsx | 22 + .../hosts/NotificationListWidgetHost.tsx | 57 + .../src/widgets/hosts/OrderbookWidgetHost.tsx | 51 + .../src/widgets/hosts/PaperKpiWidgetHost.tsx | 15 + .../widgets/hosts/PendingOrdersWidgetHost.tsx | 66 + .../src/widgets/hosts/PositionsWidgetHost.tsx | 68 + .../src/widgets/hosts/QuoteWidgetHost.tsx | 57 + .../widgets/hosts/StrategyChartWidgetHost.tsx | 30 + .../widgets/hosts/StrategyListWidgetHost.tsx | 41 + .../widgets/hosts/TickerTapeWidgetHost.tsx | 73 + .../src/widgets/hosts/TimeSalesWidgetHost.tsx | 55 + .../widgets/hosts/TradeHistoryWidgetHost.tsx | 26 + .../src/widgets/hosts/WatchlistWidgetHost.tsx | 87 ++ .../src/widgets/panels/WidgetPanelHeader.tsx | 45 + .../src/widgets/panels/WidgetSymbolList.tsx | 54 + frontend/src/widgets/widgetMarketUtils.ts | 55 + frontend/src/widgets/widgetRegistry.ts | 267 ++++ 57 files changed, 6728 insertions(+), 540 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V66__widget_dashboard_menu.sql create mode 100644 frontend/src/components/IndicatorDropdownPanel.tsx create mode 100644 frontend/src/components/WidgetDashboardPage.tsx create mode 100644 frontend/src/components/floatingWidgets/FloatingWidgetLayer.tsx create mode 100644 frontend/src/components/floatingWidgets/FloatingWidgetLayoutPicker.tsx create mode 100644 frontend/src/components/floatingWidgets/FloatingWidgetWindow.tsx create mode 100644 frontend/src/components/widgetDashboard/LayoutEditorModal.tsx create mode 100644 frontend/src/components/widgetDashboard/WidgetDashboardShell.tsx create mode 100644 frontend/src/components/widgetDashboard/WidgetPickerModal.tsx create mode 100644 frontend/src/components/widgetDashboard/WidgetSidePanel.tsx create mode 100644 frontend/src/components/widgetDashboard/WidgetSlotView.tsx create mode 100644 frontend/src/components/widgetDashboard/WidgetZoneView.tsx create mode 100644 frontend/src/hooks/useFloatingWidgetResize.ts create mode 100644 frontend/src/styles/floatingWidget.css create mode 100644 frontend/src/styles/widgetDashboard.css create mode 100644 frontend/src/types/floatingWidget.ts create mode 100644 frontend/src/types/widgetDashboard.ts create mode 100644 frontend/src/widgets/WidgetCandleChartPanel.tsx create mode 100644 frontend/src/widgets/WidgetDashboardContext.tsx create mode 100644 frontend/src/widgets/hosts/AccountBalanceWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/BacktestListWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/BuySellWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/CandleChartWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/DepthChartWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/MarketHeatmapWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/MarketListWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/MarketMoversWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/MarketSummaryWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/MiniChartWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/NotificationListWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/OrderbookWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/PaperKpiWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/PendingOrdersWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/PositionsWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/QuoteWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/StrategyChartWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/StrategyListWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/TickerTapeWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/TimeSalesWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/TradeHistoryWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/WatchlistWidgetHost.tsx create mode 100644 frontend/src/widgets/panels/WidgetPanelHeader.tsx create mode 100644 frontend/src/widgets/panels/WidgetSymbolList.tsx create mode 100644 frontend/src/widgets/widgetMarketUtils.ts create mode 100644 frontend/src/widgets/widgetRegistry.ts diff --git a/backend/src/main/java/com/goldenchart/auth/MenuIds.java b/backend/src/main/java/com/goldenchart/auth/MenuIds.java index cf72b52..2c82181 100644 --- a/backend/src/main/java/com/goldenchart/auth/MenuIds.java +++ b/backend/src/main/java/com/goldenchart/auth/MenuIds.java @@ -7,7 +7,7 @@ public final class MenuIds { private MenuIds() {} public static final List ALL = List.of( - "dashboard", "chart", "paper", "virtual", "trend-search", "verification-board", "strategy", "strategy-editor", "strategy-evaluation", "backtest", "analysis-report", "notifications", "settings", + "dashboard", "widget-dashboard", "chart", "paper", "virtual", "trend-search", "verification-board", "strategy", "strategy-editor", "strategy-evaluation", "backtest", "analysis-report", "notifications", "settings", "settings_general", "settings_chart", "settings_indicators", "settings_backtest", "settings_strategy", "settings_trading", "settings_paper", "settings_virtual", "settings_live", "settings_trend-search", diff --git a/backend/src/main/resources/db/migration/V66__widget_dashboard_menu.sql b/backend/src/main/resources/db/migration/V66__widget_dashboard_menu.sql new file mode 100644 index 0000000..bf69141 --- /dev/null +++ b/backend/src/main/resources/db/migration/V66__widget_dashboard_menu.sql @@ -0,0 +1,6 @@ +-- 위젯 대시보드 메뉴 권한 (대시보드와 동일 정책) +INSERT INTO gc_role_menu_permission (role, menu_id, allowed) VALUES +('ADMIN', 'widget-dashboard', 1), +('USER', 'widget-dashboard', 1), +('GUEST', 'widget-dashboard', 0) +ON DUPLICATE KEY UPDATE allowed = VALUES(allowed); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f3f7942..2d00f65 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { } from './utils/tradingAccess'; import { NotifyTopMenuBar } from './components/NotifyUiBindings'; import { AppNotificationLayer } from './components/AppNotificationLayer'; +import FloatingWidgetLayer from './components/floatingWidgets/FloatingWidgetLayer'; import type { Theme } from './types'; import type { ChartRealtimeSource } from './hooks/useChartRealtimeData'; import { useIsMobile } from './hooks/useMediaQuery'; @@ -74,6 +75,7 @@ const VirtualTradingPage = lazy(() => import('./components/VirtualTradingPage')) const StrategyEvaluationPage = lazy(() => import('./components/StrategyEvaluationPage')); const TrendSearchPage = lazy(() => import('./components/TrendSearchPage')); const VerificationBoardPage = lazy(() => import('./components/VerificationBoardPage')); +const WidgetDashboardPage = lazy(() => import('./components/WidgetDashboardPage')); function PageLoadFallback() { return
화면 로딩 중…
; @@ -165,6 +167,10 @@ function AppMainContent({ const paperTradingEnabled = appDefaults.paperTradingEnabled ?? true; const paperAutoTradeEnabled = appDefaults.paperAutoTradeEnabled ?? false; + const [floatingLayoutPickerOpen, setFloatingLayoutPickerOpen] = useState(false); + const [floatingWidgetCount, setFloatingWidgetCount] = useState(0); + const showFloatingWidgets = canMenu('widget-dashboard'); + const handleFullscreen = () => { if (!document.fullscreenElement) document.documentElement.requestFullscreen().catch(() => {}); else document.exitFullscreen().catch(() => {}); @@ -194,6 +200,8 @@ function AppMainContent({ onTradeAlertPopupLayout={v => saveAppDef({ tradeAlertPopupLayout: v })} onTradeAlertPopupGridCols={v => saveAppDef({ tradeAlertPopupGridCols: v })} onFullscreen={handleFullscreen} + onOpenFloatingWidgets={showFloatingWidgets ? () => setFloatingLayoutPickerOpen(true) : undefined} + floatingWidgetCount={floatingWidgetCount} /> )} + {menuPage === 'widget-dashboard' && ( + }> + + + )} + {menuPage === 'strategy' && ( formalLogin ? ( @@ -441,6 +461,20 @@ function AppMainContent({ {chartSlot} + {showFloatingWidgets && ( + + )} + ( + + + +); + +const IcPlus = () => ( + + + +); + +const IcGear = () => ( + + + + +); + +const BUILTIN_TABS: Array<{ key: IndicatorTabKey; label: string }> = [ + { key: 'All', label: '전체' }, + { key: 'Main', label: '주요지표' }, +]; + +export interface IndicatorDropdownPanelProps { + activeIndicators: IndicatorConfig[]; + onAdd: (type: string) => void; + onAddMany?: (types: string[]) => void; + onIndicatorOrderChange?: (orderedTypes: string[]) => void; + onRemove: (type: string) => void; + onOpenSettings?: (type: string) => void; + onClose: () => void; +} + +const IndicatorDropdownPanel: React.FC = ({ + activeIndicators, + onAdd, + onAddMany, + onIndicatorOrderChange, + onRemove, + onOpenSettings, + onClose, +}) => { + const [search, setSearch] = useState(''); + const [category, setCategory] = useState('All'); + const [customTabs, setCustomTabs] = useState(() => loadCustomTabs()); + const [mainOrderTick, setMainOrderTick] = useState(0); + const [customTabsRevision, setCustomTabsRevision] = useState(0); + const [draggingType, setDraggingType] = useState(null); + const [dragOverType, setDragOverType] = useState(null); + const [editorOpen, setEditorOpen] = useState(false); + const [editorMode, setEditorMode] = useState<'create' | 'edit'>('create'); + const searchRef = useRef(null); + const searchFocusedRef = useRef(false); + + const refreshCustomTabs = useCallback(() => { + setCustomTabs(loadCustomTabs()); + setCustomTabsRevision(t => t + 1); + }, []); + + useEffect(() => { + const onTabsChanged = () => refreshCustomTabs(); + window.addEventListener('gc-indicator-custom-tabs-changed', onTabsChanged); + return () => window.removeEventListener('gc-indicator-custom-tabs-changed', onTabsChanged); + }, [refreshCustomTabs]); + + const selectedCustomTab = React.useMemo(() => { + const id = parseCustomTabKey(category); + return id ? customTabs.find(t => t.id === id) ?? null : null; + }, [category, customTabs]); + + const openCreateEditor = () => { + setEditorMode('create'); + setEditorOpen(true); + }; + + const openEditEditor = () => { + if (!selectedCustomTab) return; + setEditorMode('edit'); + setEditorOpen(true); + }; + + const handleDeleteTab = () => { + if (!selectedCustomTab) return; + if (!window.confirm(`"${selectedCustomTab.name}" 탭을 삭제할까요?`)) return; + deleteCustomTab(selectedCustomTab.id); + refreshCustomTabs(); + setCategory('All'); + }; + + const handleEditorSave = (name: string, indicatorTypes: string[]) => { + if (editorMode === 'create') { + const tab = createCustomTab(name, indicatorTypes); + refreshCustomTabs(); + setCategory(customTabKey(tab.id)); + } else if (selectedCustomTab) { + updateCustomTab(selectedCustomTab.id, { name, indicatorTypes }); + refreshCustomTabs(); + } + setEditorOpen(false); + setSearch(''); + }; + + const tabTypeOrder = React.useMemo((): string[] | null => { + void mainOrderTick; + void customTabsRevision; + if (search.trim()) return null; + if (category === 'Main') return getOrderedMainIndicatorTypes(); + if (isCustomTabKey(category)) { + const id = parseCustomTabKey(category); + if (!id) return null; + const tab = customTabs.find(t => t.id === id); + return tab ? [...tab.indicatorTypes] : null; + } + return null; + }, [category, customTabs, search, mainOrderTick, customTabsRevision]); + + const canReorderList = tabTypeOrder != null && tabTypeOrder.length > 1; + + const syncChartToTabOrder = useCallback(() => { + if (tabTypeOrder?.length && onIndicatorOrderChange) { + onIndicatorOrderChange(tabTypeOrder); + } + }, [tabTypeOrder, onIndicatorOrderChange]); + + const moveTypeInTabList = useCallback((fromType: string, toType: string) => { + if (!tabTypeOrder) return; + const next = reorderIndicatorListType(tabTypeOrder, fromType, toType, 'before'); + if (category === 'Main') { + saveMainTabOrder(next); + setMainOrderTick(t => t + 1); + saveIndicatorListOrder(mergeIndicatorListOrder(next, getSettingsIndicatorTypes())); + onIndicatorOrderChange?.(next); + } else if (isCustomTabKey(category)) { + const id = parseCustomTabKey(category); + if (!id) return; + const updated = updateCustomTab(id, { indicatorTypes: next }); + if (updated) { + setCustomTabs(prev => prev.map(t => (t.id === id ? updated : t))); + setCustomTabsRevision(t => t + 1); + } else { + refreshCustomTabs(); + } + onIndicatorOrderChange?.(next); + } + }, [tabTypeOrder, category, refreshCustomTabs, onIndicatorOrderChange]); + + useEffect(() => { + const clearDrag = () => { + setDraggingType(null); + setDragOverType(null); + }; + document.addEventListener('dragend', clearDrag); + return () => document.removeEventListener('dragend', clearDrag); + }, []); + + const addAllContext = React.useMemo(() => { + if (category === 'All') return null; + if (category === 'Main') { + return { label: '주요지표', types: getOrderedMainIndicatorTypes() }; + } + if (isCustomTabKey(category) && selectedCustomTab) { + const types = selectedCustomTab.indicatorTypes.filter( + t => INDICATOR_REGISTRY.some(d => d.type === t), + ); + return { label: selectedCustomTab.name, types }; + } + return null; + }, [category, selectedCustomTab]); + + const addAllDisabled = category === 'All' || !addAllContext || addAllContext.types.length === 0; + + const handleAddAllInTab = () => { + if (!addAllContext || addAllContext.types.length === 0) return; + if (category === 'Main') { + confirmAndApplyIndicatorTab({ kind: 'main' }, onAddMany, onAdd); + return; + } + if (isCustomTabKey(category) && selectedCustomTab) { + confirmAndApplyIndicatorTab({ kind: 'custom', tab: selectedCustomTab }, onAddMany, onAdd); + } + }; + + const stopHeaderBtn = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + useEffect(() => { + if (editorOpen || searchFocusedRef.current) return; + searchRef.current?.focus(); + searchFocusedRef.current = true; + }, [editorOpen]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + if (editorOpen) { + setEditorOpen(false); + return; + } + onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose, editorOpen]); + + const isActive = (type: string) => activeIndicators.some(a => a.type === type); + + const filtered = React.useMemo(() => { + const q = search.toLowerCase(); + const matchesQuery = (d: IndicatorDef) => { + if (!q) return true; + const main = MAIN_INDICATOR_LABELS[d.type]; + return ( + d.name.toLowerCase().includes(q) + || d.shortName.toLowerCase().includes(q) + || d.description.includes(q) + || (main?.ko.includes(q) ?? false) + || (main?.en.toLowerCase().includes(q) ?? false) + ); + }; + + if (category === 'Main' && tabTypeOrder) { + return tabTypeOrder + .map(type => INDICATOR_REGISTRY.find(d => d.type === type)) + .filter((d): d is IndicatorDef => d != null) + .filter(matchesQuery); + } + + if (isCustomTabKey(category) && tabTypeOrder) { + return tabTypeOrder + .map(type => INDICATOR_REGISTRY.find(d => d.type === type)) + .filter((d): d is IndicatorDef => d != null) + .filter(matchesQuery); + } + + return INDICATOR_REGISTRY.filter(matchesQuery); + }, [search, category, customTabs, tabTypeOrder]); + + const { + panelRef, + dragging, + onHeaderPointerDown, + headerTouchStyle, + panelStyle, + headerCursor, + } = useDraggablePanel({ centerOnMount: true }); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
e.stopPropagation()} + > +
+ 지표 추가 +
+ + + + +
+
+ +
+
+ + { setSearch(e.target.value); setCategory('All'); }} + /> + {search && ( + + )} +
+
+ +
+
+ {BUILTIN_TABS.map(c => { + const cnt = c.key === 'All' + ? INDICATOR_REGISTRY.length + : MAIN_INDICATOR_TYPES.length; + return ( + + ); + })} + {customTabs.map(tab => ( + + ))} +
+ +
+ +
+ {activeIndicators.length > 0 && !search && category === 'All' && ( +
+
활성 지표 ({activeIndicators.length})
+ {activeIndicators.map(a => { + const lbl = getIndicatorListLabels(a.type); + return ( +
+
+ {lbl.ko} + {lbl.en} +
+
+ + {onOpenSettings && ( + + )} +
+
+ ); + })} +
+
+ )} + + {filtered.length === 0 ? ( +
+ {search + ? `"${search}" 검색 결과 없음` + : isCustomTabKey(category) + ? '이 탭에 표시할 지표가 없습니다. 수정 버튼으로 지표를 추가해 주세요.' + : '표시할 지표가 없습니다'} +
+ ) : ( + filtered.map(def => { + const active = isActive(def.type); + const mainLbl = (category === 'Main' || isCustomTabKey(category)) + ? MAIN_INDICATOR_LABELS[def.type] + : undefined; + const isDragOver = canReorderList && dragOverType === def.type && draggingType !== def.type; + return ( +
{ + if (!draggingType || draggingType === def.type) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDragOverType(def.type); + } : undefined} + onDragLeave={canReorderList ? () => { + if (dragOverType === def.type) setDragOverType(null); + } : undefined} + onDrop={canReorderList ? e => { + e.preventDefault(); + const from = e.dataTransfer.getData('text/indicator-type') || draggingType; + if (from && from !== def.type) moveTypeInTabList(from, def.type); + setDraggingType(null); + setDragOverType(null); + } : undefined} + > + {canReorderList && ( + + )} +
+ {mainLbl?.ko ?? def.description} + {mainLbl?.en ?? def.name} +
+
+ {def.overlay && 오버레이} + {active ? ( + + ) : ( + + )} + {onOpenSettings && ( + + )} +
+
+ ); + }) + )} +
+ + {editorOpen && ( + setEditorOpen(false)} + /> + )} +
+
+ ); +}; + +export default IndicatorDropdownPanel; diff --git a/frontend/src/components/NotifyUiBindings.tsx b/frontend/src/components/NotifyUiBindings.tsx index 436beda..eba0009 100644 --- a/frontend/src/components/NotifyUiBindings.tsx +++ b/frontend/src/components/NotifyUiBindings.tsx @@ -26,6 +26,8 @@ interface NotifyTopMenuBarProps { onTradeAlertPopupLayout?: (v: TradeAlertPopupLayout) => void; onTradeAlertPopupGridCols?: (v: number) => void; onFullscreen?: () => void; + onOpenFloatingWidgets?: () => void; + floatingWidgetCount?: number; } export const NotifyTopMenuBar: React.FC = props => { diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index 5518b01..8f0b68b 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -1,38 +1,6 @@ import React, { useRef, useEffect, useState, useCallback } from 'react'; -import { useDraggablePanel } from '../hooks/useDraggablePanel'; import ReactDOM from 'react-dom'; -import { INDICATOR_REGISTRY, type IndicatorDef } from '../utils/indicatorRegistry'; -import { - MAIN_INDICATOR_TYPES, - MAIN_INDICATOR_LABELS, - customTabKey, - isCustomTabKey, - parseCustomTabKey, - type IndicatorTabKey, -} from '../utils/indicatorMainTab'; -import { - confirmAndApplyIndicatorTab, -} from '../utils/indicatorTabApply'; -import { - getOrderedMainIndicatorTypes, - saveMainTabOrder, -} from '../utils/indicatorMainTabOrder'; -import { - mergeIndicatorListOrder, - reorderIndicatorListType, - saveIndicatorListOrder, -} from '../utils/indicatorListOrder'; -import { getSettingsIndicatorTypes } from '../utils/indicatorSettingsList'; -import { - loadCustomTabs, - createCustomTab, - updateCustomTab, - deleteCustomTab, - type IndicatorCustomTab, -} from '../utils/indicatorCustomTabsStorage'; -import IndicatorCustomTabEditor from './IndicatorCustomTabEditor'; -import { getIndicatorListLabels } from '../utils/indicatorSettingsList'; -// IndicatorCategory는 IndDropdown 상태 타입에 사용됨 +import IndicatorDropdownPanel from './IndicatorDropdownPanel'; import type { ChartType, Theme, Timeframe, ChartMode, IndicatorConfig } from '../types'; import { MarketSearchPanel } from './MarketSearchPanel'; import { getKoreanName } from '../utils/marketNameCache'; @@ -165,12 +133,6 @@ const IcPlus = () => ( ); -const IcGear = () => ( - - - - -); const IcCandlestick = () => ( @@ -298,12 +260,6 @@ const IcReplay = () => ( ); -// ─── 기본 탭 (사용자 정의 탭은 localStorage) ─────────────────────────────── -const BUILTIN_TABS: Array<{ key: IndicatorTabKey; label: string }> = [ - { key: 'All', label: '전체' }, - { key: 'Main', label: '주요지표' }, -]; - // ─── 차트 타입 아이콘 매핑 ───────────────────────────────────────────────── const ChartIcon: Record = { candlestick: , @@ -322,487 +278,6 @@ const CHART_TYPES: { value: ChartType; label: string }[] = [ { value: 'baseline', label: '기준선' }, ]; -// ─── 지표 선택 오버레이 패널 ────────────────────────────────────────────── -interface IndDropdownProps { - activeIndicators: IndicatorConfig[]; - onAdd: (type: string) => void; - onAddMany?: (types: string[]) => void; - onIndicatorOrderChange?: (orderedTypes: string[]) => void; - onRemove: (type: string) => void; - onOpenSettings?: (type: string) => void; - onClose: () => void; -} - -const IndDropdown: React.FC = ({ - activeIndicators, onAdd, onAddMany, onIndicatorOrderChange, onRemove, onOpenSettings, onClose, -}) => { - const [search, setSearch] = React.useState(''); - const [category, setCategory] = React.useState('All'); - const [customTabs, setCustomTabs] = React.useState(() => loadCustomTabs()); - const [mainOrderTick, setMainOrderTick] = React.useState(0); - const [customTabsRevision, setCustomTabsRevision] = React.useState(0); - const [draggingType, setDraggingType] = React.useState(null); - const [dragOverType, setDragOverType] = React.useState(null); - const [editorOpen, setEditorOpen] = React.useState(false); - const [editorMode, setEditorMode] = React.useState<'create' | 'edit'>('create'); - const searchRef = useRef(null); - const searchFocusedRef = useRef(false); - - const refreshCustomTabs = useCallback(() => { - setCustomTabs(loadCustomTabs()); - setCustomTabsRevision(t => t + 1); - }, []); - - useEffect(() => { - const onTabsChanged = () => refreshCustomTabs(); - window.addEventListener('gc-indicator-custom-tabs-changed', onTabsChanged); - return () => window.removeEventListener('gc-indicator-custom-tabs-changed', onTabsChanged); - }, [refreshCustomTabs]); - - const selectedCustomTab = React.useMemo(() => { - const id = parseCustomTabKey(category); - return id ? customTabs.find(t => t.id === id) ?? null : null; - }, [category, customTabs]); - - const openCreateEditor = () => { - setEditorMode('create'); - setEditorOpen(true); - }; - - const openEditEditor = () => { - if (!selectedCustomTab) return; - setEditorMode('edit'); - setEditorOpen(true); - }; - - const handleDeleteTab = () => { - if (!selectedCustomTab) return; - if (!window.confirm(`"${selectedCustomTab.name}" 탭을 삭제할까요?`)) return; - deleteCustomTab(selectedCustomTab.id); - refreshCustomTabs(); - setCategory('All'); - }; - - const handleEditorSave = (name: string, indicatorTypes: string[]) => { - if (editorMode === 'create') { - const tab = createCustomTab(name, indicatorTypes); - refreshCustomTabs(); - setCategory(customTabKey(tab.id)); - } else if (selectedCustomTab) { - updateCustomTab(selectedCustomTab.id, { name, indicatorTypes }); - refreshCustomTabs(); - } - setEditorOpen(false); - setSearch(''); - }; - - const tabTypeOrder = React.useMemo((): string[] | null => { - void mainOrderTick; - void customTabsRevision; - if (search.trim()) return null; - if (category === 'Main') return getOrderedMainIndicatorTypes(); - if (isCustomTabKey(category)) { - const id = parseCustomTabKey(category); - if (!id) return null; - const tab = customTabs.find(t => t.id === id); - return tab ? [...tab.indicatorTypes] : null; - } - return null; - }, [category, customTabs, search, mainOrderTick, customTabsRevision]); - - const canReorderList = tabTypeOrder != null && tabTypeOrder.length > 1; - - const syncChartToTabOrder = useCallback(() => { - if (tabTypeOrder?.length && onIndicatorOrderChange) { - onIndicatorOrderChange(tabTypeOrder); - } - }, [tabTypeOrder, onIndicatorOrderChange]); - - const moveTypeInTabList = useCallback((fromType: string, toType: string) => { - if (!tabTypeOrder) return; - const next = reorderIndicatorListType(tabTypeOrder, fromType, toType, 'before'); - if (category === 'Main') { - saveMainTabOrder(next); - setMainOrderTick(t => t + 1); - saveIndicatorListOrder(mergeIndicatorListOrder(next, getSettingsIndicatorTypes())); - onIndicatorOrderChange?.(next); - } else if (isCustomTabKey(category)) { - const id = parseCustomTabKey(category); - if (!id) return; - const updated = updateCustomTab(id, { indicatorTypes: next }); - if (updated) { - setCustomTabs(prev => prev.map(t => (t.id === id ? updated : t))); - setCustomTabsRevision(t => t + 1); - } else { - refreshCustomTabs(); - } - onIndicatorOrderChange?.(next); - } - }, [tabTypeOrder, category, refreshCustomTabs, onIndicatorOrderChange]); - - useEffect(() => { - const clearDrag = () => { - setDraggingType(null); - setDragOverType(null); - }; - document.addEventListener('dragend', clearDrag); - return () => document.removeEventListener('dragend', clearDrag); - }, []); - - const addAllContext = React.useMemo(() => { - if (category === 'All') return null; - if (category === 'Main') { - return { label: '주요지표', types: getOrderedMainIndicatorTypes() }; - } - if (isCustomTabKey(category) && selectedCustomTab) { - const types = selectedCustomTab.indicatorTypes.filter( - t => INDICATOR_REGISTRY.some(d => d.type === t), - ); - return { label: selectedCustomTab.name, types }; - } - return null; - }, [category, selectedCustomTab]); - - const addAllDisabled = category === 'All' || !addAllContext || addAllContext.types.length === 0; - - const handleAddAllInTab = () => { - if (!addAllContext || addAllContext.types.length === 0) return; - if (category === 'Main') { - confirmAndApplyIndicatorTab({ kind: 'main' }, onAddMany, onAdd); - return; - } - if (isCustomTabKey(category) && selectedCustomTab) { - confirmAndApplyIndicatorTab({ kind: 'custom', tab: selectedCustomTab }, onAddMany, onAdd); - } - }; - - const stopHeaderBtn = (e: React.MouseEvent) => { - e.stopPropagation(); - }; - - useEffect(() => { - if (editorOpen || searchFocusedRef.current) return; - searchRef.current?.focus(); - searchFocusedRef.current = true; - }, [editorOpen]); - - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key !== 'Escape') return; - if (editorOpen) { - setEditorOpen(false); - return; - } - onClose(); - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [onClose, editorOpen]); - - const isActive = (type: string) => activeIndicators.some(a => a.type === type); - - const filtered = React.useMemo(() => { - const q = search.toLowerCase(); - const matchesQuery = (d: IndicatorDef) => { - if (!q) return true; - const main = MAIN_INDICATOR_LABELS[d.type]; - return ( - d.name.toLowerCase().includes(q) - || d.shortName.toLowerCase().includes(q) - || d.description.includes(q) - || (main?.ko.includes(q) ?? false) - || (main?.en.toLowerCase().includes(q) ?? false) - ); - }; - - if (category === 'Main' && tabTypeOrder) { - return tabTypeOrder - .map(type => INDICATOR_REGISTRY.find(d => d.type === type)) - .filter((d): d is IndicatorDef => d != null) - .filter(matchesQuery); - } - - if (isCustomTabKey(category) && tabTypeOrder) { - return tabTypeOrder - .map(type => INDICATOR_REGISTRY.find(d => d.type === type)) - .filter((d): d is IndicatorDef => d != null) - .filter(matchesQuery); - } - - return INDICATOR_REGISTRY.filter(matchesQuery); - }, [search, category, customTabs, tabTypeOrder]); - - const { - panelRef, - dragging, - onHeaderPointerDown, headerTouchStyle, - panelStyle, - headerCursor, - } = useDraggablePanel({ centerOnMount: true }); - - return ( - // 배경 클릭 시 닫힘 -
{ if (e.target === e.currentTarget) onClose(); }}> -
e.stopPropagation()} - > -
- 지표 추가 -
- - - - -
-
- - {/* 검색창 */} -
-
- - { setSearch(e.target.value); setCategory('All'); }} - /> - {search && ( - - )} -
-
- - {/* 카테고리 탭 */} -
-
- {BUILTIN_TABS.map(c => { - const cnt = c.key === 'All' - ? INDICATOR_REGISTRY.length - : MAIN_INDICATOR_TYPES.length; - return ( - - ); - })} - {customTabs.map(tab => ( - - ))} -
- -
- - {/* 지표 목록 */} -
- {/* 활성 지표가 있을 때 상단에 표시 */} - {activeIndicators.length > 0 && !search && category === 'All' && ( -
-
활성 지표 ({activeIndicators.length})
- {activeIndicators.map(a => { - const lbl = getIndicatorListLabels(a.type); - return ( -
-
- {lbl.ko} - {lbl.en} -
-
- - {onOpenSettings && ( - - )} -
-
- ); - })} -
-
- )} - - {filtered.length === 0 ? ( -
- {search - ? `"${search}" 검색 결과 없음` - : isCustomTabKey(category) - ? '이 탭에 표시할 지표가 없습니다. 수정 버튼으로 지표를 추가해 주세요.' - : '표시할 지표가 없습니다'} -
- ) : ( - filtered.map(def => { - const active = isActive(def.type); - const mainLbl = (category === 'Main' || isCustomTabKey(category)) - ? MAIN_INDICATOR_LABELS[def.type] - : undefined; - const isDragOver = canReorderList && dragOverType === def.type && draggingType !== def.type; - return ( -
{ - if (!draggingType || draggingType === def.type) return; - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - setDragOverType(def.type); - } : undefined} - onDragLeave={canReorderList ? () => { - if (dragOverType === def.type) setDragOverType(null); - } : undefined} - onDrop={canReorderList ? e => { - e.preventDefault(); - const from = e.dataTransfer.getData('text/indicator-type') || draggingType; - if (from && from !== def.type) moveTypeInTabList(from, def.type); - setDraggingType(null); - setDragOverType(null); - } : undefined} - > - {canReorderList && ( - - )} -
- {mainLbl?.ko ?? def.description} - {mainLbl?.en ?? def.name} -
-
- {def.overlay && 오버레이} - {active ? ( - - ) : ( - - )} - {onOpenSettings && ( - - )} -
-
- ); - }) - )} -
- - {editorOpen && ( - setEditorOpen(false)} - /> - )} - -
-
- ); -}; // ─── Toolbar ────────────────────────────────────────────────────────────── const Toolbar: React.FC = ({ @@ -1038,7 +513,7 @@ const Toolbar: React.FC = ({ {/* 지표 패널은 fixed overlay로 렌더링 — 부모 overflow 영향 없음 */} {showIndMenu && ( - onAddIndicator(type)} onAddMany={onAddIndicators} diff --git a/frontend/src/components/TopMenuBar.tsx b/frontend/src/components/TopMenuBar.tsx index 68d2e4f..976959d 100644 --- a/frontend/src/components/TopMenuBar.tsx +++ b/frontend/src/components/TopMenuBar.tsx @@ -10,7 +10,7 @@ import type { AuthSession } from '../utils/auth'; import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout'; import { canAccessMenu } from '../utils/permissions'; -export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'verification-board' | 'strategy' | 'strategy-editor' | 'strategy-evaluation' | 'backtest' | 'analysis-report' | 'notifications' | 'settings'; +export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'verification-board' | 'strategy' | 'strategy-editor' | 'strategy-evaluation' | 'backtest' | 'analysis-report' | 'notifications' | 'settings' | 'widget-dashboard'; interface TopMenuBarProps { activePage: MenuPage; @@ -42,6 +42,10 @@ interface TopMenuBarProps { onLogout?: () => void; /** 브라우저 전체화면 (모든 화면에서 사용) */ onFullscreen?: () => void; + /** 플로팅 위젯 레이아웃 선택 열기 */ + onOpenFloatingWidgets?: () => void; + /** 실행 중인 플로팅 위젯 창 수 */ + floatingWidgetCount?: number; /** 메뉴별 접근 허용 (없으면 전체 표시) */ menuPermissions?: Record; } @@ -196,8 +200,28 @@ const IcVerificationBoard = () => ( ); +const IcWidget = () => ( + + + + + + +); + +const IcFloatingWidgetAdd = () => ( + + + + + + + +); + const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [ { page: 'dashboard', label: '대시보드', icon: }, + { page: 'widget-dashboard', label: '위젯', icon: }, { page: 'chart', label: '실시간차트', icon: }, { page: 'paper', label: '투자관리', icon: }, { page: 'virtual', label: '가상매매', icon: }, @@ -228,6 +252,8 @@ export const TopMenuBar = memo(function TopMenuBar({ onLoginClick, onLogout, onFullscreen, + onOpenFloatingWidgets, + floatingWidgetCount = 0, menuPermissions, }: TopMenuBarProps) { const [isFullscreen, setIsFullscreen] = useState(() => !!document.fullscreenElement); @@ -281,6 +307,25 @@ export const TopMenuBar = memo(function TopMenuBar({ {/* 우측 영역 */}
+ {onOpenFloatingWidgets && ( +
+ + {floatingWidgetCount > 0 && ( + + {floatingWidgetCount > 9 ? '9+' : floatingWidgetCount} + + )} +
+ )} + {onOpenAppDownload && ( <> + ))} +
+ + ); +}; + +export default FloatingWidgetLayoutPicker; diff --git a/frontend/src/components/floatingWidgets/FloatingWidgetWindow.tsx b/frontend/src/components/floatingWidgets/FloatingWidgetWindow.tsx new file mode 100644 index 0000000..0577cec --- /dev/null +++ b/frontend/src/components/floatingWidgets/FloatingWidgetWindow.tsx @@ -0,0 +1,175 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useDraggablePanel } from '../../hooks/useDraggablePanel'; +import { useFloatingWidgetResize } from '../../hooks/useFloatingWidgetResize'; +import { minSizeForGrid, type FloatingWidgetInstance } from '../../types/floatingWidget'; +import type { WidgetSlot } from '../../types/widgetDashboard'; +import WidgetSlotView from '../widgetDashboard/WidgetSlotView'; +import WidgetPickerModal from '../widgetDashboard/WidgetPickerModal'; + +interface Props { + instance: FloatingWidgetInstance; + focused: boolean; + onClose: () => void; + onFocus: () => void; + onUpdateSlots: (slots: WidgetSlot[]) => void; + onUpdateSize: (width: number, height: number) => void; +} + +const FloatingWidgetWindow: React.FC = ({ + instance, + focused, + onClose, + onFocus, + onUpdateSlots, + onUpdateSize, +}) => { + const [pickSlotId, setPickSlotId] = useState(null); + const bodyRef = useRef(null); + + const { + panelRef, + setPos, + dragging, + onHeaderPointerDown, + headerTouchStyle, + panelStyle, + headerCursor, + } = useDraggablePanel({ + centerOnMount: false, + initialPosition: instance.position, + }); + + const minSize = useMemo( + () => minSizeForGrid(instance.rows, instance.cols), + [instance.rows, instance.cols], + ); + + const handleResize = useCallback((result: { width: number; height: number; x?: number }) => { + if (result.x != null) { + setPos(prev => ({ ...prev, x: result.x! })); + } + onUpdateSize(result.width, result.height); + }, [onUpdateSize, setPos]); + + const { onResizeSePointerDown, onResizeSwPointerDown } = useFloatingWidgetResize( + panelRef, + { width: instance.width, height: instance.height }, + minSize, + handleResize, + ); + + const updateSlot = useCallback((slotId: string, updater: (s: WidgetSlot) => WidgetSlot) => { + onUpdateSlots(instance.slots.map(s => (s.id === slotId ? updater(s) : s))); + }, [instance.slots, onUpdateSlots]); + + const applyWidgetType = useCallback((widgetType: string) => { + if (!pickSlotId) return; + updateSlot(pickSlotId, s => ({ ...s, widgetType, config: {} })); + setPickSlotId(null); + }, [pickSlotId, updateSlot]); + + const clearWidget = useCallback((slotId: string) => { + updateSlot(slotId, s => ({ ...s, widgetType: null, config: {} })); + }, [updateSlot]); + + /* 창 크기 변경 시 내부 차트·위젯 레이아웃 재동기화 */ + useEffect(() => { + const el = bodyRef.current; + if (!el) return undefined; + let timer: ReturnType | null = null; + const ro = new ResizeObserver(() => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + window.dispatchEvent(new CustomEvent('gc-widget-layout-sync')); + }, 80); + }); + ro.observe(el); + return () => { + if (timer) clearTimeout(timer); + ro.disconnect(); + }; + }, []); + + const title = `위젯 ${instance.rows}×${instance.cols}`; + + return createPortal( + <> +
+
+ {title} + +
+ +
+ {instance.slots.map(slot => ( +
+ setPickSlotId(id)} + onClearWidget={id => clearWidget(id)} + /> +
+ ))} +
+ +
+
+
+
+
+ + setPickSlotId(null)} + onSelect={applyWidgetType} + /> + , + document.body, + ); +}; + +export default FloatingWidgetWindow; diff --git a/frontend/src/components/layout/BuilderPageShell.tsx b/frontend/src/components/layout/BuilderPageShell.tsx index 376caf3..ae94122 100644 --- a/frontend/src/components/layout/BuilderPageShell.tsx +++ b/frontend/src/components/layout/BuilderPageShell.tsx @@ -53,6 +53,14 @@ export interface BuilderPageShellProps { collapsiblePanels?: boolean; leftCollapsedStorageKey?: string; rightCollapsedStorageKey?: string; + /** 패널 크기·접기 변경 시 (위젯 대시보드 레이아웃 영속화 등) */ + onPanelLayoutChange?: (patch: { + leftWidth?: number; + rightWidth?: number; + footerHeight?: number; + leftOpen?: boolean; + rightOpen?: boolean; + }) => void; loading?: boolean; loadingText?: string; } @@ -129,6 +137,7 @@ export default function BuilderPageShell({ collapsiblePanels = false, leftCollapsedStorageKey, rightCollapsedStorageKey, + onPanelLayoutChange, loading = false, loadingText = '로딩 중…', }: BuilderPageShellProps) { @@ -158,7 +167,10 @@ export default function BuilderPageShell({ () => leftWidthRef.current, LEFT_MIN, LEFT_MAX, - v => storeSize(leftStorageKey, v), + v => { + storeSize(leftStorageKey, v); + onPanelLayoutChange?.({ leftWidth: v }); + }, ); const handleRightSplitter = useCallback((e: React.PointerEvent) => { @@ -186,11 +198,12 @@ export default function BuilderPageShell({ window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); storeSize(rightStorageKey, rightWidthRef.current); + onPanelLayoutChange?.({ rightWidth: rightWidthRef.current }); }; window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp); - }, [rightStorageKey]); + }, [rightStorageKey, onPanelLayoutChange]); const onFooterSplitter = usePanelResize( 'horizontal', @@ -198,24 +211,29 @@ export default function BuilderPageShell({ () => footerHeightRef.current, FOOTER_MIN, FOOTER_MAX, - v => storeSize(footerStorageKey, v), + v => { + storeSize(footerStorageKey, v); + onPanelLayoutChange?.({ footerHeight: v }); + }, ); const toggleLeft = useCallback(() => { setLeftOpen(prev => { const next = !prev; if (leftCollapsedStorageKey) storeBool(leftCollapsedStorageKey, next); + onPanelLayoutChange?.({ leftOpen: next }); return next; }); - }, [leftCollapsedStorageKey]); + }, [leftCollapsedStorageKey, onPanelLayoutChange]); const toggleRight = useCallback(() => { setRightOpen(prev => { const next = !prev; if (rightCollapsedStorageKey) storeBool(rightCollapsedStorageKey, next); + onPanelLayoutChange?.({ rightOpen: next }); return next; }); - }, [rightCollapsedStorageKey]); + }, [rightCollapsedStorageKey, onPanelLayoutChange]); const pageStyle = useMemo(() => ({ '--bps-left-width': leftOpen ? `${leftWidth}px` : '0px', diff --git a/frontend/src/components/widgetDashboard/LayoutEditorModal.tsx b/frontend/src/components/widgetDashboard/LayoutEditorModal.tsx new file mode 100644 index 0000000..e8de79c --- /dev/null +++ b/frontend/src/components/widgetDashboard/LayoutEditorModal.tsx @@ -0,0 +1,361 @@ +import React, { useCallback, useState } from 'react'; +import AppPopup from '../AppPopup'; +import type { + WidgetLayoutSchema, + WidgetZoneConfig, + WidgetZoneId, + WidgetZoneMode, +} from '../../types/widgetDashboard'; +import { + defaultWidgetLayout, + newWidgetSlot, + normalizeZoneSlots, + slotsForSplitCount, +} from '../../types/widgetDashboard'; +import { addTabToZone, removeTabFromZone } from './WidgetZoneView'; + +interface Props { + open: boolean; + layout: WidgetLayoutSchema; + onClose: () => void; + onApply: (layout: WidgetLayoutSchema) => void; +} + +type ZoneKey = 'left' | 'center' | 'right' | 'bottom'; + +const ZONE_META: Record = { + left: { title: '좌측 패널', desc: '종목 목록·전략 목록 등 보조 위젯' }, + center: { title: '중앙 (메인)', desc: '차트·분석 등 메인 위젯 영역' }, + right: { title: '우측 패널', desc: '매수·매도·호가 등 거래 위젯' }, + bottom: { title: '하단 패널', desc: '거래 내역·알림 등 하단 위젯' }, +}; + +const MODE_OPTIONS: { value: WidgetZoneMode; label: string; desc: string }[] = [ + { value: 'single', label: '단일', desc: '위젯 1개' }, + { value: 'split', label: '상하 분할', desc: '2~3개 세로 배치' }, + { value: 'tabs', label: '탭', desc: '탭별 위젯 구성' }, +]; + +function setZoneEnabled( + layout: WidgetLayoutSchema, + key: ZoneKey, + enabled: boolean, +): WidgetLayoutSchema { + if (key === 'center') return layout; + const defaults = defaultWidgetLayout(); + return { + ...layout, + zones: { + ...layout.zones, + [key]: enabled ? (layout.zones[key] ?? defaults.zones[key]) : null, + }, + }; +} + +function updateZoneConfig( + layout: WidgetLayoutSchema, + key: ZoneKey, + patch: Partial, +): WidgetLayoutSchema { + const current = layout.zones[key]; + if (!current && key !== 'center') return layout; + const base = current ?? defaultWidgetLayout().zones.center; + let next: WidgetZoneConfig = normalizeZoneSlots({ ...base, ...patch }); + + if (patch.mode === 'split' && patch.splitCount) { + const defaults = slotsForSplitCount(patch.splitCount, (base.slots ?? []).map(s => s.widgetType)); + next = normalizeZoneSlots({ + ...next, + mode: 'split', + splitCount: patch.splitCount, + slots: defaults.map((s, i) => ({ + ...s, + widgetType: base.slots?.[i]?.widgetType ?? s.widgetType, + config: base.slots?.[i]?.config ?? {}, + })), + tabs: undefined, + }); + } + + if (patch.mode === 'tabs' && !next.tabs?.length) { + next = addTabToZone({ ...next, mode: 'tabs' }, '탭 1'); + } + + if (patch.mode === 'single') { + next = normalizeZoneSlots({ + mode: 'single', + splitCount: 1, + slots: [base.slots?.[0] ?? newWidgetSlot()], + }); + } + + return { + ...layout, + zones: { + ...layout.zones, + [key]: next, + }, + }; +} + +const SettingRow: React.FC<{ + label: string; + desc?: string; + className?: string; + children: React.ReactNode; +}> = ({ label, desc, className, children }) => ( +
+
+ {label} + {desc && {desc}} +
+
{children}
+
+); + +const LayoutEditorModal: React.FC = ({ + open, + layout, + onClose, + onApply, +}) => { + const [draft, setDraft] = useState(layout); + + React.useEffect(() => { + if (open) setDraft(layout); + }, [open, layout]); + + const apply = useCallback(() => { + onApply(draft); + onClose(); + }, [draft, onApply, onClose]); + + if (!open) return null; + + const renderZoneEditor = (key: ZoneKey) => { + const enabled = key === 'center' || draft.zones[key] != null; + const zone = key === 'center' + ? draft.zones.center + : (draft.zones[key] ?? defaultWidgetLayout().zones[key]); + + if (!zone) return null; + + const mode = zone.mode; + const splitCount = zone.splitCount ?? 1; + const meta = ZONE_META[key]; + + return ( +
+
{meta.title}
+
+ {key !== 'center' && ( + + + + )} + + {enabled && ( + <> + +
+ {MODE_OPTIONS.map(opt => ( + + ))} +
+
+ + {mode === 'split' && ( + + + + )} + + {mode === 'tabs' && ( + <> +
+ 탭 목록 + 탭 이름과 탭 내부 분할 수를 설정합니다 +
+ {(zone.tabs ?? []).map(tab => ( +
+
+ + + {(zone.tabs?.length ?? 0) > 1 && ( + + )} +
+
+ ))} +
+ +
+ + )} + + )} +
+
+ ); + }; + + return ( + + + +
+ )} + > +

+ 좌·중·우·하단 패널의 표시 여부와 내부 구성을 설정합니다. + 변경 내용은 저장 후 새로고침·다른 기기에서도 동일하게 복원됩니다. +

+ {(['left', 'center', 'right', 'bottom'] as ZoneKey[]).map(renderZoneEditor)} + + ); +}; + +export default LayoutEditorModal; diff --git a/frontend/src/components/widgetDashboard/WidgetDashboardShell.tsx b/frontend/src/components/widgetDashboard/WidgetDashboardShell.tsx new file mode 100644 index 0000000..e7ec7b7 --- /dev/null +++ b/frontend/src/components/widgetDashboard/WidgetDashboardShell.tsx @@ -0,0 +1,316 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import BuilderPageShell from '../layout/BuilderPageShell'; +import type { Theme } from '../../types'; +import type { + WidgetLayoutSchema, + WidgetZoneConfig, + WidgetZoneId, +} from '../../types/widgetDashboard'; +import { defaultWidgetLayout } from '../../types/widgetDashboard'; +import { storeBool, storeSize } from '../strategyEditor/usePanelResize'; +import WidgetZoneView, { resolveActiveTabId, WidgetZoneTabBar } from './WidgetZoneView'; +import WidgetSidePanel from './WidgetSidePanel'; +import WidgetPickerModal from './WidgetPickerModal'; +import LayoutEditorModal from './LayoutEditorModal'; + +interface Props { + theme: Theme; + layout: WidgetLayoutSchema; + onLayoutChange: (layout: WidgetLayoutSchema, opts?: { immediate?: boolean }) => void; +} + +interface PickTarget { + slotId: string; + zoneId: WidgetZoneId; + tabId?: string; +} + +type SideZoneId = 'left' | 'right'; + +function zoneUsesTabs(zone: WidgetZoneConfig | null | undefined): boolean { + return zone?.mode === 'tabs' && (zone.tabs?.length ?? 0) > 0; +} + +const WidgetDashboardShell: React.FC = ({ + theme, + layout, + onLayoutChange, +}) => { + const [layoutEditorOpen, setLayoutEditorOpen] = useState(false); + const [pickTarget, setPickTarget] = useState(null); + const [activeTabByZone, setActiveTabByZone] = useState>>({}); + + const pickCategory = pickTarget?.zoneId === 'center' ? 'center' : 'side'; + + const getActiveTab = useCallback((zoneId: WidgetZoneId, zone: WidgetZoneConfig | null | undefined) => + resolveActiveTabId(zone, activeTabByZone[zoneId]), + [activeTabByZone]); + + const setActiveTab = useCallback((zoneId: WidgetZoneId, tabId: string) => { + setActiveTabByZone(prev => ({ ...prev, [zoneId]: tabId })); + }, []); + + useEffect(() => { + setActiveTabByZone(prev => { + const next = { ...prev }; + let changed = false; + (['left', 'center', 'right', 'bottom'] as WidgetZoneId[]).forEach(zoneId => { + const zone = zoneId === 'center' ? layout.zones.center : layout.zones[zoneId]; + if (!zoneUsesTabs(zone)) return; + const resolved = resolveActiveTabId(zone, prev[zoneId]); + if (resolved && resolved !== prev[zoneId]) { + next[zoneId] = resolved; + changed = true; + } + }); + return changed ? next : prev; + }); + }, [layout]); + + const updateZone = useCallback((zoneId: WidgetZoneId, zone: WidgetZoneConfig) => { + onLayoutChange({ + ...layout, + zones: { + ...layout.zones, + [zoneId]: zoneId === 'center' ? zone : zone, + }, + }); + }, [layout, onLayoutChange]); + + const pageClass = [ + 'bps-page--wd', + layout.zones.left ? '' : 'bps-page--wd-no-left', + layout.zones.right ? '' : 'bps-page--wd-no-right', + ].filter(Boolean).join(' '); + + const applyWidgetType = useCallback((widgetType: string) => { + if (!pickTarget) return; + const { slotId, zoneId, tabId } = pickTarget; + const zone = layout.zones[zoneId]; + if (!zone) return; + + const patchSlot = (s: { id: string; widgetType: string | null; config?: Record }) => + s.id === slotId ? { ...s, widgetType, config: {} } : s; + + let nextLayout: WidgetLayoutSchema; + if (zone.mode === 'tabs' && tabId) { + nextLayout = { + ...layout, + zones: { + ...layout.zones, + [zoneId]: { + ...zone, + tabs: (zone.tabs ?? []).map(tab => + tab.id === tabId + ? { ...tab, slots: tab.slots.map(patchSlot) } + : tab, + ), + }, + }, + }; + } else { + nextLayout = { + ...layout, + zones: { + ...layout.zones, + [zoneId]: { + ...zone, + slots: (zone.slots ?? []).map(patchSlot), + }, + }, + }; + } + onLayoutChange(nextLayout, { immediate: true }); + setPickTarget(null); + }, [pickTarget, layout, onLayoutChange]); + + const renderZoneView = useCallback(( + zoneId: WidgetZoneId, + zone: WidgetZoneConfig, + options?: { hideTabBar?: boolean }, + ) => ( + updateZone(zoneId, z)} + onPickWidget={(slotId, zid, tabId) => setPickTarget({ slotId, zoneId: zid, tabId })} + hideTabBar={options?.hideTabBar} + activeTabId={getActiveTab(zoneId, zone)} + onActiveTabChange={tabId => setActiveTab(zoneId, tabId)} + /> + ), [getActiveTab, setActiveTab, updateZone]); + + const renderSideTabBar = useCallback(( + zoneId: SideZoneId, + zone: WidgetZoneConfig, + ) => ( + setActiveTab(zoneId, tabId)} + variant="side" + /> + ), [getActiveTab, setActiveTab]); + + const leftZone = layout.zones.left; + const rightZone = layout.zones.right; + const bottomZone = layout.zones.bottom; + const centerZone = layout.zones.center; + + const leftUsesTabs = zoneUsesTabs(leftZone); + const rightUsesTabs = zoneUsesTabs(rightZone); + const bottomUsesTabs = zoneUsesTabs(bottomZone); + const centerUsesTabs = zoneUsesTabs(centerZone); + + const leftPanelContent = leftZone + ? renderZoneView('left', leftZone, { hideTabBar: leftUsesTabs }) + : null; + + const rightPanelContent = rightZone + ? renderZoneView('right', rightZone, { hideTabBar: rightUsesTabs }) + : null; + + const leftPanel = leftPanelContent && leftUsesTabs && leftZone + ? ( + + {leftPanelContent} + + ) + : leftPanelContent; + + const rightPanel = rightPanelContent && rightUsesTabs && rightZone + ? ( + + {rightPanelContent} + + ) + : rightPanelContent; + + const centerPanel = renderZoneView('center', centerZone, { hideTabBar: centerUsesTabs }); + + const footerPanel = bottomZone + ? ( +
+ {bottomUsesTabs && bottomZone && ( + setActiveTab('bottom', tabId)} + variant="panel" + /> + )} + {renderZoneView('bottom', bottomZone, { hideTabBar: bottomUsesTabs })} +
+ ) + : null; + + const centerHead = centerUsesTabs && centerZone + ? ( +
+ setActiveTab('center', tabId)} + variant="panel" + /> +
+ ) + : undefined; + + const headerActions = ( + <> + + + ); + + const storageKeys = useMemo(() => ({ + left: 'wd-left-width', + right: 'wd-right-width', + footer: 'wd-footer-height', + leftCollapsed: 'wd-left-open', + rightCollapsed: 'wd-right-open', + }), []); + + useEffect(() => { + const { panelSizes, collapsed } = layout; + if (panelSizes.leftWidth != null) storeSize(storageKeys.left, panelSizes.leftWidth); + if (panelSizes.rightWidth != null) storeSize(storageKeys.right, panelSizes.rightWidth); + if (panelSizes.footerHeight != null) storeSize(storageKeys.footer, panelSizes.footerHeight); + if (collapsed.left != null) storeBool(storageKeys.leftCollapsed, !collapsed.left); + if (collapsed.right != null) storeBool(storageKeys.rightCollapsed, !collapsed.right); + }, [layout, storageKeys]); + + const handlePanelLayoutChange = useCallback((patch: { + leftWidth?: number; + rightWidth?: number; + footerHeight?: number; + leftOpen?: boolean; + rightOpen?: boolean; + }) => { + onLayoutChange({ + ...layout, + panelSizes: { + ...layout.panelSizes, + ...(patch.leftWidth != null ? { leftWidth: patch.leftWidth } : {}), + ...(patch.rightWidth != null ? { rightWidth: patch.rightWidth } : {}), + ...(patch.footerHeight != null ? { footerHeight: patch.footerHeight } : {}), + }, + collapsed: { + ...layout.collapsed, + ...(patch.leftOpen != null ? { left: !patch.leftOpen } : {}), + ...(patch.rightOpen != null ? { right: !patch.rightOpen } : {}), + }, + }); + }, [layout, onLayoutChange]); + + return ( + <> + } + centerHead={centerHead} + center={centerPanel} + right={rightPanel ?? undefined} + footer={footerPanel ?? undefined} + footerLabel={bottomUsesTabs ? undefined : '하단'} + leftTitle={layout.zones.left && !leftUsesTabs ? '좌측' : undefined} + rightTitle={layout.zones.right && !rightUsesTabs ? '우측' : undefined} + /> + setPickTarget(null)} + onSelect={applyWidgetType} + /> + setLayoutEditorOpen(false)} + onApply={draft => onLayoutChange(draft, { immediate: true })} + /> + + ); +}; + +export default WidgetDashboardShell; + +export { defaultWidgetLayout }; diff --git a/frontend/src/components/widgetDashboard/WidgetPickerModal.tsx b/frontend/src/components/widgetDashboard/WidgetPickerModal.tsx new file mode 100644 index 0000000..28c6c1c --- /dev/null +++ b/frontend/src/components/widgetDashboard/WidgetPickerModal.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import AppPopup from '../AppPopup'; +import type { WidgetCategory } from '../../types/widgetDashboard'; +import { widgetsByGroup, widgetsByGroupAll } from '../../widgets/widgetRegistry'; + +export type WidgetPickerCategory = WidgetCategory | 'all'; + +interface Props { + open: boolean; + category: WidgetPickerCategory; + onClose: () => void; + onSelect: (widgetType: string) => void; +} + +const WidgetPickerModal: React.FC = ({ + open, + category, + onClose, + onSelect, +}) => { + if (!open) return null; + + const groups = category === 'all' ? widgetsByGroupAll() : widgetsByGroup(category); + const title = category === 'center' + ? '메인 위젯' + : category === 'side' + ? '패널 위젯' + : '위젯 선택'; + const intro = category === 'center' + ? '중앙 영역에 표시할 메인 위젯을 선택하세요.' + : category === 'side' + ? '좌·우·하단 패널에 추가할 위젯을 선택하세요. 주식 HTS/위젯 프로그램에서 흔히 쓰는 구성을 지원합니다.' + : '추가할 위젯 유형을 선택하세요. 차트·시세·매매·포트폴리오 등 모든 위젯을 사용할 수 있습니다.'; + + return ( + +

{intro}

+ {groups.map(g => ( +
+

{g.label}

+
+ {g.items.map(w => ( + + ))} +
+
+ ))} +
+ ); +}; + +export default WidgetPickerModal; diff --git a/frontend/src/components/widgetDashboard/WidgetSidePanel.tsx b/frontend/src/components/widgetDashboard/WidgetSidePanel.tsx new file mode 100644 index 0000000..b3a0e02 --- /dev/null +++ b/frontend/src/components/widgetDashboard/WidgetSidePanel.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +interface SidePanelProps { + tabs?: React.ReactNode; + children: React.ReactNode; +} + +/** 좌·우 side zone — se-palette-panel 과 동일 카드 + 상단 탭 */ +export function WidgetSidePanel({ tabs, children }: SidePanelProps) { + return ( +
+ {tabs} +
{children}
+
+ ); +} + +export default WidgetSidePanel; diff --git a/frontend/src/components/widgetDashboard/WidgetSlotView.tsx b/frontend/src/components/widgetDashboard/WidgetSlotView.tsx new file mode 100644 index 0000000..e098ab2 --- /dev/null +++ b/frontend/src/components/widgetDashboard/WidgetSlotView.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import type { WidgetSlot as WidgetSlotModel } from '../../types/widgetDashboard'; +import type { WidgetZoneId } from '../../types/widgetDashboard'; +import { getWidgetDefinition, widgetLabel } from '../../widgets/widgetRegistry'; + +interface Props { + slot: WidgetSlotModel; + zoneId: WidgetZoneId; + onPickWidget: (slotId: string) => void; + onClearWidget?: (slotId: string) => void; +} + +const WidgetSlotView: React.FC = ({ + slot, + zoneId, + onPickWidget, + onClearWidget, +}) => { + const def = getWidgetDefinition(slot.widgetType); + const category = zoneId === 'center' ? 'center' : 'side'; + const Host = def?.Host; + const filledEmbed = Boolean(Host); + + return ( +
+ {!filledEmbed && ( +
+ {widgetLabel(slot.widgetType)} +
+ {slot.widgetType && onClearWidget && ( + + )} +
+
+ )} +
+ {Host ? ( + + ) : ( +
+ +

+ {category === 'center' ? '메인 위젯을 추가하세요' : '패널 위젯을 추가하세요'} +

+
+ )} + {Host && ( + <> + {filledEmbed && onClearWidget && ( + + )} + + + )} +
+
+ ); +}; + +export default WidgetSlotView; diff --git a/frontend/src/components/widgetDashboard/WidgetZoneView.tsx b/frontend/src/components/widgetDashboard/WidgetZoneView.tsx new file mode 100644 index 0000000..fccf6d9 --- /dev/null +++ b/frontend/src/components/widgetDashboard/WidgetZoneView.tsx @@ -0,0 +1,172 @@ +import React, { useCallback, useMemo } from 'react'; +import type { + WidgetSlot, + WidgetTabConfig, + WidgetZoneConfig, + WidgetZoneId, +} from '../../types/widgetDashboard'; +import { newWidgetTab } from '../../types/widgetDashboard'; +import WidgetSlotView from './WidgetSlotView'; + +interface Props { + zoneId: WidgetZoneId; + zone: WidgetZoneConfig; + onChange: (zone: WidgetZoneConfig) => void; + onPickWidget: (slotId: string, zoneId: WidgetZoneId, tabId?: string) => void; + /** 패널 헤더 탭 — Shell에서 TabBar를 렌더할 때 true */ + hideTabBar?: boolean; + activeTabId?: string | null; + onActiveTabChange?: (tabId: string) => void; +} + +type TabBarVariant = 'left' | 'right' | 'panel' | 'side'; + +interface TabBarProps { + tabs: WidgetTabConfig[]; + activeTabId: string | null; + onSelect: (tabId: string) => void; + variant?: TabBarVariant; +} + +/** 전략 편집기 se-right-tabs 스타일 (지표 | 템플릿) */ +export const WidgetZoneTabBar: React.FC = ({ + tabs, + activeTabId, + onSelect, + variant = 'left', +}) => { + const isSide = variant === 'left' || variant === 'right' || variant === 'side'; + const wrapClass = isSide ? 'se-right-tabs wd-side-tabs' : 'se-right-tabs wd-panel-tabs'; + + return ( +
+ {tabs.map(tab => ( + + ))} +
+ ); +}; + +export function resolveActiveTabId( + zone: WidgetZoneConfig | null | undefined, + preferredId: string | null | undefined, +): string | null { + if (!zone || zone.mode !== 'tabs') return null; + const tabs = zone.tabs ?? []; + if (tabs.length === 0) return null; + if (preferredId && tabs.some(t => t.id === preferredId)) return preferredId; + return tabs[0].id; +} + +const WidgetZoneView: React.FC = ({ + zoneId, + zone, + onChange, + onPickWidget, + hideTabBar = false, + activeTabId: activeTabIdProp, + onActiveTabChange, +}) => { + const activeTabId = useMemo( + () => resolveActiveTabId(zone, activeTabIdProp), + [zone, activeTabIdProp], + ); + + const updateSlot = useCallback(( + slotId: string, + updater: (s: WidgetSlot) => WidgetSlot, + tabId?: string, + ) => { + if (zone.mode === 'tabs' && tabId) { + onChange({ + ...zone, + tabs: (zone.tabs ?? []).map(tab => + tab.id === tabId + ? { ...tab, slots: tab.slots.map(s => (s.id === slotId ? updater(s) : s)) } + : tab, + ), + }); + return; + } + onChange({ + ...zone, + slots: (zone.slots ?? []).map(s => (s.id === slotId ? updater(s) : s)), + }); + }, [zone, onChange]); + + const clearWidget = useCallback((slotId: string, tabId?: string) => { + updateSlot(slotId, s => ({ ...s, widgetType: null, config: {} }), tabId); + }, [updateSlot]); + + const renderSlots = (slots: WidgetSlot[], tabId?: string) => ( +
+ {slots.map(slot => ( + onPickWidget(id, zoneId, tabId)} + onClearWidget={id => clearWidget(id, tabId)} + /> + ))} +
+ ); + + if (zone.mode === 'tabs') { + const tabs = zone.tabs ?? []; + const activeTab = tabs.find(t => t.id === activeTabId) ?? tabs[0]; + + return ( +
+ {!hideTabBar && ( + onActiveTabChange?.(id)} + variant="side" + /> + )} + {activeTab && renderSlots(activeTab.slots, activeTab.id)} +
+ ); + } + + const slots = zone.slots ?? []; + return ( +
+ {renderSlots(slots)} +
+ ); +}; + +export default WidgetZoneView; + +export function addTabToZone(zone: WidgetZoneConfig, label = '탭'): WidgetZoneConfig { + const tab = newWidgetTab(label, 1); + return { + ...zone, + mode: 'tabs', + tabs: [...(zone.tabs ?? []), tab], + slots: undefined, + }; +} + +export function removeTabFromZone(zone: WidgetZoneConfig, tabId: string): WidgetZoneConfig { + const tabs = (zone.tabs ?? []).filter(t => t.id !== tabId); + if (tabs.length === 0) { + return { mode: 'single', splitCount: 1, slots: zone.slots ?? [] }; + } + return { ...zone, tabs }; +} diff --git a/frontend/src/hooks/useAppSettings.ts b/frontend/src/hooks/useAppSettings.ts index 979108c..f90509d 100644 --- a/frontend/src/hooks/useAppSettings.ts +++ b/frontend/src/hooks/useAppSettings.ts @@ -60,6 +60,7 @@ import { type ChartPaneSeparatorOptions, } from '../types/chartPaneSeparator'; import { migrateLocalStorageToUiPreferences } from '../utils/uiPreferencesMigrate'; +import { reconcilePendingUiPreferencesOnLoad } from '../utils/uiPreferencesDb'; import { loadLocalTheme } from '../utils/localThemeStorage'; // 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지 @@ -79,6 +80,11 @@ export function getAppSettingsCache(): AppSettingsDto { return _cache ?? {}; } +/** DB에서 앱 설정 로드가 완료되었는지 (null 캐시 = 미로드) */ +export function isAppSettingsCacheLoaded(): boolean { + return _cache !== null; +} + /** 낙관적 캐시 패치 — _cache 가 null 일 때도 다음 로드 전까지 임시 보관 */ export function patchAppSettingsCache(patch: Partial): void { if (_cache !== null) { @@ -102,6 +108,7 @@ function ensureLoaded(): Promise { return _cache ?? {}; } _cache = data ?? {}; + reconcilePendingUiPreferencesOnLoad(); const migrated = migrateLocalStorageToUiPreferences(_cache); if (migrated?.changed) { try { diff --git a/frontend/src/hooks/useDraggablePanel.ts b/frontend/src/hooks/useDraggablePanel.ts index afab0f9..7ba0e63 100644 --- a/frontend/src/hooks/useDraggablePanel.ts +++ b/frontend/src/hooks/useDraggablePanel.ts @@ -116,6 +116,7 @@ export function useDraggablePanel(options: UseDraggablePanelOptions = {}) { return { panelRef: internalRef, pos, + setPos, dragging, /** @deprecated onHeaderPointerDown 사용 */ onHeaderMouseDown: onHeaderPointerDown, diff --git a/frontend/src/hooks/useFloatingWidgetResize.ts b/frontend/src/hooks/useFloatingWidgetResize.ts new file mode 100644 index 0000000..b07809e --- /dev/null +++ b/frontend/src/hooks/useFloatingWidgetResize.ts @@ -0,0 +1,124 @@ +import { useCallback, useEffect, useRef, type RefObject } from 'react'; +import { + bindWindowDrag, + isPrimaryPointerButton, +} from '../utils/pointerDrag'; + +export interface Size2d { + width: number; + height: number; +} + +export interface Position2d { + x: number; + y: number; +} + +export interface FloatingWidgetResizeResult { + width: number; + height: number; + x?: number; +} + +export type FloatingWidgetResizeCorner = 'se' | 'sw'; + +function clampSize( + width: number, + height: number, + minSize: Size2d, + maxW: number, + maxH: number, +): Size2d { + return { + width: Math.round(Math.max(minSize.width, Math.min(maxW, width))), + height: Math.round(Math.max(minSize.height, Math.min(maxH, height))), + }; +} + +export function useFloatingWidgetResize( + panelRef: RefObject, + size: Size2d, + minSize: Size2d, + onResize: (result: FloatingWidgetResizeResult) => void, +) { + const unbindDragRef = useRef<(() => void) | null>(null); + + const endResize = useCallback(() => { + unbindDragRef.current?.(); + unbindDragRef.current = null; + }, []); + + useEffect(() => () => { + unbindDragRef.current?.(); + }, []); + + const startResize = useCallback(( + e: React.PointerEvent, + corner: FloatingWidgetResizeCorner, + ) => { + if (!isPrimaryPointerButton(e)) return; + e.preventDefault(); + e.stopPropagation(); + + const handle = e.currentTarget as HTMLElement; + try { + handle.setPointerCapture(e.pointerId); + } catch { + /* ignore */ + } + + const el = panelRef.current; + if (!el) return; + + const rect = el.getBoundingClientRect(); + const startX = e.clientX; + const startY = e.clientY; + const startW = size.width; + const startH = size.height; + const startRight = rect.right; + const maxH = window.innerHeight - rect.top - 8; + + unbindDragRef.current?.(); + unbindDragRef.current = bindWindowDrag( + (xy) => { + if (corner === 'se') { + const maxW = window.innerWidth - rect.left - 8; + const next = clampSize( + startW + xy.x - startX, + startH + xy.y - startY, + minSize, + maxW, + maxH, + ); + onResize(next); + return; + } + + /* sw — 우측 가장자리 고정, 좌측·하단 조절 */ + let width = startRight - xy.x; + let x = xy.x; + if (x < 8) { + x = 8; + width = startRight - 8; + } + const maxW = startRight - 8; + const next = clampSize(width, startH + xy.y - startY, minSize, maxW, maxH); + onResize({ ...next, x: Math.round(startRight - next.width) }); + }, + endResize, + { preventTouchScroll: true }, + ); + }, [panelRef, size.width, size.height, minSize, onResize, endResize]); + + const onResizeSePointerDown = useCallback( + (e: React.PointerEvent) => startResize(e, 'se'), + [startResize], + ); + + const onResizeSwPointerDown = useCallback( + (e: React.PointerEvent) => startResize(e, 'sw'), + [startResize], + ); + + return { onResizeSePointerDown, onResizeSwPointerDown }; +} diff --git a/frontend/src/hooks/useUpbitRecentTrades.ts b/frontend/src/hooks/useUpbitRecentTrades.ts index d53d2db..bab60ff 100644 --- a/frontend/src/hooks/useUpbitRecentTrades.ts +++ b/frontend/src/hooks/useUpbitRecentTrades.ts @@ -11,7 +11,7 @@ export interface RecentTrade { time: number; } -const MAX_TRADES = 12; +const MAX_TRADES_DEFAULT = 12; interface UpbitTradeRaw { market: string; @@ -30,9 +30,11 @@ interface UpbitTradeWs { timestamp: number; } -export function useUpbitRecentTrades(market: string, enabled = true) { +export function useUpbitRecentTrades(market: string, enabled = true, maxTrades = MAX_TRADES_DEFAULT) { const [trades, setTrades] = useState([]); const [strength, setStrength] = useState(null); + const maxRef = useRef(maxTrades); + maxRef.current = maxTrades; const mountedRef = useRef(true); const marketRef = useRef(market); @@ -40,7 +42,8 @@ export function useUpbitRecentTrades(market: string, enabled = true) { const pushTrade = useCallback((t: RecentTrade) => { setTrades(prev => { - const next = [t, ...prev].slice(0, MAX_TRADES); + const cap = maxRef.current; + const next = [t, ...prev].slice(0, cap); const buyVol = next.filter(x => x.side === 'bid').reduce((s, x) => s + x.volume, 0); const sellVol = next.filter(x => x.side === 'ask').reduce((s, x) => s + x.volume, 0); if (sellVol > 0) setStrength((buyVol / sellVol) * 100); @@ -51,7 +54,7 @@ export function useUpbitRecentTrades(market: string, enabled = true) { const fetchSnapshot = useCallback(async () => { try { - const res = await fetch(`/upbit-api/v1/trades/ticks?market=${marketRef.current}&count=${MAX_TRADES}`); + const res = await fetch(`/upbit-api/v1/trades/ticks?market=${marketRef.current}&count=${maxRef.current}`); if (!res.ok) return; const data: UpbitTradeRaw[] = await res.json(); const list: RecentTrade[] = data.map(d => ({ diff --git a/frontend/src/styles/floatingWidget.css b/frontend/src/styles/floatingWidget.css new file mode 100644 index 0000000..f408a8b --- /dev/null +++ b/frontend/src/styles/floatingWidget.css @@ -0,0 +1,420 @@ +/* ── 플로팅 위젯 (always-on-top 팝업) ── */ +.fw-layer { + pointer-events: none; + position: fixed; + inset: 0; + z-index: 13000; +} + +.fw-window { + /* body 포털 — .se-page 테마 토큰 미상속 보정 */ + --se-bg: var(--bg); + --se-bg-elevated: var(--bg2); + --se-bg-muted: var(--bg3); + --se-text: var(--text); + --se-text-muted: var(--text2); + --se-text-dim: var(--text3); + --se-border: var(--border); + --se-accent: var(--accent); + --se-accent-hover: var(--accent-h); + --se-up: var(--up); + --se-down: var(--down); + --se-buy: var(--gc-trade-buy, #ef5350); + --se-sell: var(--gc-trade-sell, #4dabf7); + --se-panel-card-bg: var(--bg2); + --se-panel-card-border: var(--border); + --se-center-bg: var(--bg2); + --wd-slot-border: color-mix(in srgb, var(--border) 85%, transparent); + --wd-slot-bg: color-mix(in srgb, var(--bg2) 92%, transparent); + color: var(--text); + + pointer-events: auto; + position: fixed; + display: flex; + flex-direction: column; + border-radius: 10px; + border: 1px solid color-mix(in srgb, var(--se-accent, #7aa2f7) 52%, transparent); + background: var(--se-panel-card-bg, var(--bg2)); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--se-accent, #7aa2f7) 18%, transparent), + 0 16px 48px rgba(0, 0, 0, 0.55); + overflow: hidden; + min-width: 200px; + min-height: 160px; + max-width: calc(100vw - 12px); + max-height: calc(100vh - 12px); +} + +.fw-window--focused { + border-color: color-mix(in srgb, var(--se-accent, #7aa2f7) 68%, transparent); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--se-accent, #7aa2f7) 28%, transparent), + 0 20px 56px rgba(0, 0, 0, 0.62); +} + +.fw-window-head { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px 6px 12px; + border-bottom: 1px solid color-mix(in srgb, var(--se-border, var(--border)) 75%, transparent); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--se-accent, #3f7ef5) 16%, var(--bg2, #1e222d)) 0%, + color-mix(in srgb, var(--se-accent, #3f7ef5) 5%, var(--bg2, #1e222d)) 55%, + color-mix(in srgb, var(--bg2, #1e222d) 98%, var(--se-accent, #3f7ef5)) 100% + ); + cursor: grab; + user-select: none; + touch-action: none; +} + +.fw-window-head:active { + cursor: grabbing; +} + +.fw-window-title { + flex: 1; + min-width: 0; + font-size: 0.72rem; + font-weight: 700; + color: var(--se-text, var(--text)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.fw-window-close { + flex-shrink: 0; + width: 26px; + height: 26px; + border: 1px solid color-mix(in srgb, var(--se-border) 80%, transparent); + border-radius: 6px; + background: color-mix(in srgb, var(--bg2) 90%, transparent); + color: var(--se-text-muted, var(--text2)); + font-size: 0.75rem; + cursor: pointer; + line-height: 1; +} + +.fw-window-close:hover { + border-color: #e74c3c; + color: #e74c3c; + background: color-mix(in srgb, #e74c3c 12%, transparent); +} + +.fw-window-body { + flex: 1; + min-height: 0; + display: grid; + gap: 1px; + padding: 0; + background: color-mix(in srgb, var(--se-border) 40%, transparent); + overflow: hidden; +} + +.fw-window-cell { + min-height: 0; + min-width: 0; + display: flex; + flex-direction: column; + background: var(--se-center-bg, var(--bg2)); + overflow: hidden; +} + +.fw-window-cell .wd-slot { + flex: 1; + min-height: 0; + border: none; + border-radius: 0; +} + +.fw-window-cell .wd-slot-body { + flex: 1; + min-height: 0; +} + +.fw-window-cell .wd-widget-host { + flex: 1; + min-height: 0; + width: 100%; + height: 100%; +} + +/* 리사이즈 핸들 — 좌·우 하단 (차트 툴바 위에 표시) */ +.fw-window-resize-layer { + position: absolute; + inset: 0; + z-index: 100; + pointer-events: none; +} + +.fw-window-resize-handle { + position: absolute; + bottom: 0; + width: 20px; + height: 20px; + touch-action: none; + pointer-events: auto; +} + +.fw-window-resize-handle--se { + right: 0; + cursor: nwse-resize; + background: radial-gradient( + circle at 100% 100%, + color-mix(in srgb, var(--bg2) 92%, transparent) 0%, + transparent 72% + ); +} + +.fw-window-resize-handle--sw { + left: 0; + cursor: nesw-resize; + background: radial-gradient( + circle at 0% 100%, + color-mix(in srgb, var(--bg2) 92%, transparent) 0%, + transparent 72% + ); +} + +.fw-window-resize-handle::after { + content: ''; + position: absolute; + bottom: 4px; + width: 10px; + height: 10px; + pointer-events: none; +} + +.fw-window-resize-handle--se::after { + right: 4px; + border-right: 2px solid color-mix(in srgb, var(--se-text-muted) 70%, transparent); + border-bottom: 2px solid color-mix(in srgb, var(--se-text-muted) 70%, transparent); + border-radius: 0 0 2px 0; +} + +.fw-window-resize-handle--sw::after { + left: 4px; + border-left: 2px solid color-mix(in srgb, var(--se-text-muted) 70%, transparent); + border-bottom: 2px solid color-mix(in srgb, var(--se-text-muted) 70%, transparent); + border-radius: 0 0 0 2px; +} + +.fw-window-resize-handle:hover::after, +.fw-window--focused .fw-window-resize-handle::after { + border-color: var(--se-accent, var(--accent)); +} + +/* ── 내부 위젯 fit (리사이즈 시 영역 채움) ── */ +.fw-window-cell .wd-slot--filled { + flex: 1; + min-height: 0; + height: 100%; + width: 100%; +} + +.fw-window-cell .wd-widget-host > *:only-child { + flex: 1; + min-height: 0; + min-width: 0; +} + +.fw-window-cell .wd-widget-host--panel > .wd-panel-header, +.fw-window-cell .wd-widget-host--panel > .wd-panel-subline { + flex: 0 0 auto; +} + +.fw-window-cell .wd-widget-host--panel > .wd-panel-body, +.fw-window-cell .wd-widget-host--panel > .wd-mini-chart-wrap { + flex: 1; + min-height: 0; +} + +.fw-window-cell .wd-widget-host--chart, +.fw-window-cell .wd-candle-chart { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.fw-window-cell .vtd-card-chart-canvas, +.fw-window-cell .slot-chart-wrap, +.fw-window-cell .wd-candle-chart-canvas { + flex: 1; + min-height: 0 !important; + height: auto !important; + max-height: none; + border-radius: 0; +} + +.fw-window-cell .wd-candle-chart-main { + flex: 1; + min-height: 0; +} + +.fw-window-cell .wd-widget-host--market .market-panel { + height: 100% !important; + flex: 1; +} + +.fw-window-cell .wd-widget-host--market .mp-body, +.fw-window-cell .wd-widget-host--market .mp-list { + flex: 1; + min-height: 0; +} + +.fw-window-cell .wd-widget-host--trade, +.fw-window-cell .wd-widget-host--orderbook { + overflow: hidden; +} + +.fw-window-cell .wd-widget-host--trade > *, +.fw-window-cell .wd-widget-host--orderbook > * { + flex: 1; + min-height: 0; + overflow: auto; +} + +.fw-window-cell .wd-widget-host--mini-chart .ptd-chart-wrap { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.fw-window-cell .wd-widget-host--mini-chart .ptd-chart-canvas { + flex: 1; + min-height: 0 !important; + max-height: none !important; +} + +.fw-window-cell .ptd-analysis-chart { + flex: 1; + min-height: 0; + height: 100%; +} + +.fw-window-cell .ptd-analysis-chart .ptd-chart-canvas { + max-height: none; + min-height: 0; + flex: 1; +} + +.fw-window-cell .wd-panel-body { + flex: 1; + min-height: 0; + overflow: auto; +} + +.fw-window-cell .wd-widget-host--history .ptd-trade-history-scroll { + flex: 1; + min-height: 0; + overflow: auto; +} + +/* 레이아웃 선택 팝업 */ +.fw-layout-picker-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} + +@media (max-width: 520px) { + .fw-layout-picker-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +.fw-layout-option { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 12px 10px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg3); + cursor: pointer; + color: var(--text); + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; +} + +.fw-layout-option:hover { + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); + background: color-mix(in srgb, var(--bg3) 80%, var(--accent) 20%); + box-shadow: 0 2px 10px color-mix(in srgb, var(--accent) 15%, transparent); +} + +.fw-layout-preview { + display: grid; + gap: 3px; + width: 100%; + max-width: 88px; + aspect-ratio: 4 / 3; +} + +.fw-layout-preview-cell { + border-radius: 3px; + background: color-mix(in srgb, var(--accent) 22%, var(--bg2)); + border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); +} + +.fw-layout-label { + font-size: 0.78rem; + font-weight: 700; +} + +.fw-layout-desc { + font-size: 0.62rem; + color: var(--text3, var(--text2)); +} + +/* 메뉴바 위젯 실행 버튼 */ +.tmb-floating-widget-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + padding: 0; + border: 1px solid color-mix(in srgb, var(--se-border) 80%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--se-accent, #3f7ef5) 10%, transparent); + color: var(--se-accent, var(--accent)); + cursor: pointer; +} + +.tmb-floating-widget-btn:hover { + background: color-mix(in srgb, var(--se-accent, #3f7ef5) 20%, transparent); + border-color: var(--se-accent, var(--accent)); +} + +.tmb-floating-widget-btn--active { + background: color-mix(in srgb, var(--se-accent, #3f7ef5) 28%, transparent); + border-color: var(--se-accent, var(--accent)); +} + +.tmb-floating-widget-count { + position: absolute; + top: -4px; + right: -4px; + min-width: 14px; + height: 14px; + padding: 0 3px; + border-radius: 7px; + background: var(--se-accent, #3f7ef5); + color: #fff; + font-size: 9px; + font-weight: 700; + line-height: 14px; + text-align: center; +} + +.tmb-floating-widget-wrap { + position: relative; + display: inline-flex; +} diff --git a/frontend/src/styles/widgetDashboard.css b/frontend/src/styles/widgetDashboard.css new file mode 100644 index 0000000..3588759 --- /dev/null +++ b/frontend/src/styles/widgetDashboard.css @@ -0,0 +1,1392 @@ +/* ── 위젯 대시보드 ── */ +.bps-page--wd { + --wd-slot-border: color-mix(in srgb, var(--se-border, var(--border)) 85%, transparent); + --wd-slot-bg: color-mix(in srgb, var(--bg2, #12161c) 92%, transparent); +} + +.wd-loading { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-height: 200px; + color: var(--se-text-muted, var(--text2)); + font-size: 0.85rem; +} + +.wd-toolbar-btn { + padding: 5px 12px; + border-radius: 6px; + border: 1px solid color-mix(in srgb, var(--se-accent, #3f7ef5) 45%, var(--se-border)); + background: color-mix(in srgb, var(--se-accent, #3f7ef5) 12%, transparent); + color: var(--se-text, var(--text)); + font-size: 0.72rem; + font-weight: 700; + cursor: pointer; +} + +.wd-toolbar-btn:hover { + background: color-mix(in srgb, var(--se-accent, #3f7ef5) 22%, transparent); +} + +.wd-zone { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + height: 100%; +} + +.wd-zone-slots { + flex: 1; + min-height: 0; + display: grid; + gap: 6px; + padding: 4px; + height: 100%; + width: 100%; +} + +.wd-zone-slots > .wd-slot { + min-height: 0; + min-width: 0; + height: 100%; + width: 100%; +} + +.wd-zone-slots--1 { grid-template-rows: 1fr; } +.wd-zone-slots--2 { grid-template-rows: 1fr 1fr; } +.wd-zone-slots--3 { grid-template-rows: 1fr 1fr 1fr; } + +.wd-slot { + min-height: 0; + min-width: 0; + display: flex; + flex-direction: column; + border: 1px solid var(--wd-slot-border); + border-radius: 8px; + background: var(--wd-slot-bg); + overflow: hidden; + position: relative; +} + +.wd-slot--filled { + border: none; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.wd-slot--filled-center { + min-height: 0; +} + +.wd-slot-body { + flex: 1; + min-height: 0; + min-width: 0; + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.wd-widget-host { + flex: 1; + min-height: 0; + min-width: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.wd-widget-host > *:only-child { + flex: 1; + min-height: 0; + min-width: 0; +} + +.wd-widget-host--panel > .wd-panel-header, +.wd-widget-host--panel > .wd-panel-subline { + flex: 0 0 auto; +} + +.wd-widget-host--panel > .wd-panel-body, +.wd-widget-host--panel > .wd-mini-chart-wrap { + flex: 1; + min-height: 0; +} + +.wd-widget-host--market .market-panel { + position: relative !important; + width: 100% !important; + min-width: 0 !important; + max-width: none !important; + flex: 1; + transform: none !important; + height: 100% !important; + box-shadow: none; + border: none; + overflow: hidden; +} + +.wd-widget-host--market .market-panel--open { + width: 100% !important; +} + +.wd-widget-host--market .mp-body { + width: 100% !important; + min-width: 0 !important; + height: 100%; + flex: 1; + min-height: 0; +} + +.wd-widget-host--market .mp-list { + flex: 1; + min-height: 0; +} + +.wd-widget-host--trade, +.wd-widget-host--orderbook { + overflow: auto; +} + +.wd-widget-host--trade > *, +.wd-widget-host--orderbook > * { + flex: 1 1 auto; + min-height: 0; +} + +.wd-slot-overlay-clear { + position: absolute; + top: 6px; + right: 6px; + z-index: 3; + width: 22px; + height: 22px; + border: 1px solid var(--wd-slot-border); + border-radius: 4px; + background: color-mix(in srgb, var(--bg2) 88%, transparent); + color: var(--se-text-muted); + font-size: 0.65rem; + cursor: pointer; + opacity: 0.55; +} + +.wd-slot-overlay-clear:hover { + opacity: 1; + color: var(--se-text); +} + +.wd-zone--tabs { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +/* ── 좌·우 side 패널 카드 (전략 편집기 se-palette-panel 동일) ── */ +.wd-side-panel { + flex: 1; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; + border-radius: 14px; + background: var(--se-panel-card-bg, var(--bg2)); + border: 1px solid var(--se-panel-card-border, var(--border)); + box-shadow: + var(--se-panel-card-shadow, 0 2px 12px rgba(0, 0, 0, 0.18)), + inset 0 1px 0 color-mix(in srgb, var(--se-text, #fff) 4%, transparent); + overflow: hidden; +} + +.wd-side-panel-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.bps-page--wd .wd-side-panel .se-right-tabs, +.bps-page--wd .wd-panel-tabs.se-right-tabs { + display: flex; + flex-shrink: 0; + border-bottom: 1px solid var(--se-border, var(--border)); +} + +.bps-page--wd .wd-side-panel .se-right-tab, +.bps-page--wd .wd-panel-tabs .se-right-tab { + flex: 1; + padding: 10px 8px; + border: none; + background: transparent; + color: var(--se-text-muted, var(--text2)); + font-size: 0.76rem; + font-weight: 700; + cursor: pointer; + transition: color 0.15s, box-shadow 0.15s, background 0.15s; +} + +.bps-page--wd .wd-side-panel .se-right-tab:hover:not(.se-right-tab--on), +.bps-page--wd .wd-panel-tabs .se-right-tab:hover:not(.se-right-tab--on) { + color: var(--se-text, var(--text)); + background: color-mix(in srgb, var(--se-text, #fff) 4%, transparent); +} + +.bps-page--wd .wd-side-panel .se-right-tab--on, +.bps-page--wd .wd-panel-tabs .se-right-tab--on { + color: var(--se-tab-active, #ffd54f); + box-shadow: inset 0 -2px 0 var(--se-tab-active, #ffd54f); +} + +.bps-page--wd .bps-panel-body > .wd-zone { + padding: 4px; + flex: 1; + min-height: 0; +} + +.bps-page--wd .wd-side-panel-body .wd-zone { + padding: 0; +} + +.bps-page--wd .wd-side-panel-body .wd-zone-slots { + padding: 0; + gap: 0; +} + +.bps-page--wd .wd-side-panel-body .wd-slot--filled { + border-radius: 0; +} + +/* 중앙·하단 zone 탭 */ +.wd-panel-tabs { + display: flex; + margin: 0; + padding: 0; +} + +.wd-center-head-tabs { + flex: 1; + min-width: 0; +} + +.wd-footer-zone { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.wd-footer-zone--tabs .wd-zone { + flex: 1; + min-height: 0; +} + +.bps-page--wd .bps-panel-body { + padding: 0; + gap: 0; +} + +.bps-page--wd .bps-side-wrap--left .bps-left--collapsible .bps-panel { + padding: 0; +} + +.bps-page--wd .bps-right-body { + padding: 0; + display: flex; + flex-direction: column; + min-height: 0; +} + +.bps-page--wd .bps-right-body .wd-side-panel { + flex: 1; + min-height: 0; +} + +.wd-zone-disabled { + display: none; +} + +.wd-slot-head { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px; + border-bottom: 1px solid var(--wd-slot-border); +} + +.wd-slot-title { + font-size: 0.65rem; + font-weight: 700; + color: var(--se-text-muted, var(--text2)); +} + +.wd-slot-action { + border: none; + background: transparent; + color: var(--se-text-muted, var(--text2)); + cursor: pointer; +} + +.wd-slot-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + height: 100%; + min-height: 80px; +} + +.wd-slot-add { + width: 44px; + height: 44px; + border-radius: 50%; + border: 2px dashed color-mix(in srgb, var(--se-accent, var(--accent)) 55%, var(--se-border, var(--border))); + background: color-mix(in srgb, var(--se-accent, var(--accent)) 8%, transparent); + color: var(--se-accent, var(--accent)); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.wd-slot-add-icon { + font-size: 1.4rem; + font-weight: 300; + line-height: 1; +} + +.wd-slot-empty-hint { + margin: 0; + font-size: 0.65rem; + color: var(--se-text-muted, var(--text2)); +} + +.wd-slot-change { + position: absolute; + right: 6px; + bottom: 6px; + width: 28px; + height: 28px; + border-radius: 50%; + border: 1px solid var(--wd-slot-border); + background: color-mix(in srgb, var(--bg2) 90%, transparent); + color: var(--se-accent, var(--accent)); + font-size: 1rem; + cursor: pointer; + opacity: 0.75; + z-index: 2; +} + +.wd-widget-host--chart { + display: flex; + flex-direction: column; + min-height: 0; +} + +.wd-candle-chart { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.wd-candle-chart-header { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + padding: 6px 8px; + border-bottom: 1px solid color-mix(in srgb, var(--se-border, var(--border)) 70%, transparent); + background: color-mix(in srgb, var(--bg2) 92%, transparent); +} + +.wd-candle-chart-market-btn { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: min(280px, 45%); + padding: 4px 10px; + border: 1px solid color-mix(in srgb, var(--se-border, var(--border)) 80%, transparent); + border-radius: 6px; + background: transparent; + color: var(--se-text, var(--text)); + font-size: 0.72rem; + cursor: pointer; + text-align: left; +} + +.wd-candle-chart-market-btn:hover { + border-color: var(--se-accent, var(--accent)); + color: var(--se-accent, var(--accent)); +} + +.wd-candle-chart-market-ko { + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.wd-candle-chart-market-sym { + font-size: 0.64rem; + color: var(--se-text-muted, var(--text-muted)); + white-space: nowrap; +} + +.wd-candle-chart-tf-row { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.wd-candle-chart-tf { + padding: 3px 8px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--se-text-muted, var(--text-muted)); + font-size: 0.64rem; + cursor: pointer; +} + +.wd-candle-chart-tf--on { + border-color: color-mix(in srgb, var(--se-accent, var(--accent)) 50%, transparent); + color: var(--se-accent, var(--accent)); + background: color-mix(in srgb, var(--se-accent, var(--accent)) 12%, transparent); +} + +.wd-candle-chart-ind-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border: 1px solid color-mix(in srgb, var(--se-border, var(--border)) 80%, transparent); + border-radius: 6px; + background: transparent; + color: var(--se-text-muted, var(--text-muted)); + font-size: 0.64rem; + cursor: pointer; +} + +.wd-candle-chart-header-actions { + display: flex; + align-items: center; + gap: 6px; + margin-left: auto; + flex-shrink: 0; +} + +.wd-candle-chart-toolbar-toggles { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 2px; + border: 1px solid color-mix(in srgb, var(--se-border, var(--border)) 80%, transparent); + border-radius: 6px; + background: color-mix(in srgb, var(--bg3, var(--bg2)) 40%, transparent); +} + +.wd-candle-chart-toolbar-toggle { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 3px 7px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--se-text-muted, var(--text2)); + font-size: 0.6rem; + font-weight: 600; + cursor: pointer; + line-height: 1; +} + +.wd-candle-chart-toolbar-toggle:hover { + color: var(--se-text, var(--text)); + background: color-mix(in srgb, var(--se-accent, var(--accent)) 10%, transparent); +} + +.wd-candle-chart-toolbar-toggle--on { + color: var(--se-accent, var(--accent)); + background: color-mix(in srgb, var(--se-accent, var(--accent)) 14%, transparent); +} + +.wd-candle-chart-ind-btn--on, +.wd-candle-chart-ind-btn:hover { + border-color: var(--se-accent, var(--accent)); + color: var(--se-accent, var(--accent)); +} + +.wd-candle-chart-ind-badge { + min-width: 1rem; + padding: 0 4px; + border-radius: 8px; + background: color-mix(in srgb, var(--se-accent, var(--accent)) 18%, transparent); + color: var(--se-accent, var(--accent)); + font-size: 0.58rem; + font-weight: 700; + text-align: center; +} + +.wd-candle-chart-mode-btn { + margin-left: 0; + padding: 4px 10px; + border: 1px solid color-mix(in srgb, var(--se-border, var(--border)) 80%, transparent); + border-radius: 6px; + background: transparent; + color: var(--se-text-muted, var(--text-muted)); + font-size: 0.64rem; + cursor: pointer; +} + +.wd-candle-chart-mode-btn:hover { + border-color: var(--se-accent, var(--accent)); + color: var(--se-accent, var(--accent)); +} + +.wd-candle-chart-main { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.wd-candle-chart-canvas { + flex: 1; + min-width: 0; + min-height: 0; + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.wd-widget-empty { + margin: 0; + padding: 16px; + text-align: center; + font-size: 0.72rem; + color: var(--se-text-muted); +} + +.wd-widget-empty-inline { + font-size: 0.62rem; + color: var(--se-text-muted); +} + +.wd-up { color: #e74c3c; } +.wd-down { color: #3498db; } +.wd-muted { color: var(--se-text-muted, var(--text-muted)); } + +.wd-widget-host--panel { + flex-direction: column; + overflow: hidden; +} + +.wd-panel-header { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + flex-wrap: wrap; + padding: 6px 8px; + border-bottom: 1px solid color-mix(in srgb, var(--se-border, var(--border)) 65%, transparent); + background: color-mix(in srgb, var(--bg2) 90%, transparent); +} + +.wd-panel-title { + font-size: 0.72rem; + font-weight: 700; + color: var(--se-text, var(--text)); +} + +.wd-panel-sub { + font-size: 0.62rem; + color: var(--se-text-muted); +} + +.wd-panel-subline { + flex-shrink: 0; + padding: 2px 10px 4px; + font-size: 0.62rem; + color: var(--se-text-muted); + border-bottom: 1px solid color-mix(in srgb, var(--se-border, var(--border)) 50%, transparent); +} + +.wd-panel-count, +.wd-panel-meta { + margin-left: auto; + font-size: 0.62rem; + color: var(--se-text-muted); +} + +.wd-panel-header-right { + margin-left: auto; +} + +.wd-panel-tabs { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.wd-panel-tab { + padding: 3px 8px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--se-text-muted); + font-size: 0.64rem; + cursor: pointer; +} + +.wd-panel-tab--on { + border-color: color-mix(in srgb, var(--se-accent) 45%, transparent); + color: var(--se-accent); + background: color-mix(in srgb, var(--se-accent) 10%, transparent); +} + +.wd-panel-body { + flex: 1; + min-height: 0; + overflow: auto; +} + +.wd-symbol-list { + list-style: none; + margin: 0; + padding: 2px 0; +} + +.wd-symbol-row { + display: grid; + grid-template-columns: auto 1fr auto auto auto; + align-items: center; + gap: 6px; + width: 100%; + padding: 5px 8px; + border: none; + border-bottom: 1px solid color-mix(in srgb, var(--se-border, var(--border)) 35%, transparent); + background: transparent; + text-align: left; + cursor: pointer; + color: inherit; + font-size: 0.68rem; +} + +.wd-symbol-row-main { + display: contents; + border: none; + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; + text-align: left; +} + +.wd-symbol-row:hover, +.wd-symbol-row--on { + background: color-mix(in srgb, var(--se-accent) 8%, transparent); +} + +.wd-symbol-rank { + width: 1.2rem; + font-size: 0.6rem; + color: var(--se-text-muted); + text-align: center; +} + +.wd-symbol-name { + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.wd-symbol-code { + font-size: 0.6rem; + color: var(--se-text-muted); +} + +.wd-symbol-price, +.wd-symbol-chg, +.wd-symbol-vol { + white-space: nowrap; + text-align: right; +} + +.wd-symbol-vol { + font-size: 0.6rem; + color: var(--se-text-muted); +} + +.wd-watch-star { + border: none; + background: transparent; + color: #f1c40f; + cursor: pointer; + font-size: 0.75rem; + padding: 0 2px; +} + +.wd-tape-viewport { + flex: 1; + overflow: hidden; + display: flex; + align-items: center; + min-height: 36px; +} + +.wd-tape-track { + display: flex; + gap: 16px; + white-space: nowrap; + will-change: transform; +} + +.wd-tape-item { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: 1px solid color-mix(in srgb, var(--se-border) 50%, transparent); + border-radius: 4px; + background: color-mix(in srgb, var(--bg2) 80%, transparent); + cursor: pointer; + font-size: 0.64rem; + color: inherit; +} + +.wd-tape-name { font-weight: 600; } +.wd-tape-chg { font-size: 0.62rem; } + +.wd-widget-host--tape { + overflow: hidden; + padding: 4px 0; +} + +.wd-heatmap-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(72px, 1fr)); + gap: 4px; + padding: 6px; +} + +.wd-heatmap-cell { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 52px; + padding: 4px; + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + color: var(--se-text, #eee); + font-size: 0.62rem; +} + +.wd-heatmap-cell--on { + border-color: var(--se-accent); + box-shadow: 0 0 0 1px var(--se-accent); +} + +.wd-heatmap-sym { font-weight: 700; } +.wd-heatmap-pct { opacity: 0.9; } + +.wd-summary-stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; + padding: 8px; +} + +.wd-summary-stat { + display: flex; + flex-direction: column; + align-items: center; + padding: 6px 4px; + border-radius: 6px; + background: color-mix(in srgb, var(--bg2) 80%, transparent); +} + +.wd-summary-stat-label { + font-size: 0.58rem; + color: var(--se-text-muted); +} + +.wd-summary-stat-val { + font-size: 0.78rem; + font-weight: 700; +} + +.wd-index-list { + list-style: none; + margin: 0; + padding: 0 4px 8px; +} + +.wd-index-row { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 8px; + width: 100%; + padding: 6px 8px; + border: none; + border-bottom: 1px solid color-mix(in srgb, var(--se-border) 35%, transparent); + background: transparent; + cursor: pointer; + font-size: 0.68rem; + color: inherit; + text-align: left; +} + +.wd-quote-body { + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.wd-quote-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin: 0; +} + +.wd-quote-grid dt { + margin: 0; + font-size: 0.58rem; + color: var(--se-text-muted); +} + +.wd-quote-grid dd { + margin: 2px 0 0; + font-size: 0.72rem; + font-weight: 600; +} + +.wd-quote-link-btn { + align-self: flex-start; + padding: 4px 10px; + border: 1px solid color-mix(in srgb, var(--se-border) 70%, transparent); + border-radius: 4px; + background: transparent; + font-size: 0.62rem; + cursor: pointer; + color: var(--se-text-muted); +} + +.wd-timesales-body { + padding: 0; +} + +.wd-timesales-table { + width: 100%; + border-collapse: collapse; + font-size: 0.64rem; +} + +.wd-timesales-table th { + position: sticky; + top: 0; + padding: 4px 6px; + background: color-mix(in srgb, var(--bg2) 95%, transparent); + color: var(--se-text-muted); + font-weight: 600; + text-align: right; + border-bottom: 1px solid color-mix(in srgb, var(--se-border) 50%, transparent); +} + +.wd-timesales-table th:first-child { text-align: left; } + +.wd-timesales-table td { + padding: 3px 6px; + text-align: right; + border-bottom: 1px solid color-mix(in srgb, var(--se-border) 25%, transparent); +} + +.wd-timesales-table td:first-child { text-align: left; color: var(--se-text-muted); } + +.wd-ts-buy td:nth-child(2) { color: #e74c3c; } +.wd-ts-sell td:nth-child(2) { color: #3498db; } + +.wd-positions-list, +.wd-orders-list { + list-style: none; + margin: 0; + padding: 4px; +} + +.wd-position-row, +.wd-order-main { + width: 100%; + padding: 8px; + border: none; + border-bottom: 1px solid color-mix(in srgb, var(--se-border) 35%, transparent); + background: transparent; + cursor: pointer; + text-align: left; + color: inherit; + font-size: 0.66rem; +} + +.wd-position-top, +.wd-position-bottom { + display: flex; + justify-content: space-between; + gap: 8px; +} + +.wd-position-bottom { + margin-top: 4px; + font-size: 0.6rem; + color: var(--se-text-muted); +} + +.wd-order-item { + display: flex; + align-items: center; + gap: 4px; + border-bottom: 1px solid color-mix(in srgb, var(--se-border) 35%, transparent); +} + +.wd-order-main { + flex: 1; + display: grid; + grid-template-columns: auto 1fr auto auto; + gap: 6px; + align-items: center; +} + +.wd-order-cancel { + flex-shrink: 0; + padding: 4px 8px; + margin-right: 4px; + border: 1px solid color-mix(in srgb, var(--se-border) 60%, transparent); + border-radius: 4px; + background: transparent; + font-size: 0.58rem; + cursor: pointer; + color: var(--se-text-muted); +} + +.wd-balance-grid { + display: flex; + flex-direction: column; + gap: 0; + margin: 0; + padding: 4px 8px; +} + +.wd-balance-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 4px; + border-bottom: 1px solid color-mix(in srgb, var(--se-border) 30%, transparent); +} + +.wd-balance-row dt { + margin: 0; + font-size: 0.66rem; + color: var(--se-text-muted); +} + +.wd-balance-row dd { + margin: 0; + font-size: 0.74rem; + font-weight: 600; +} + +.wd-balance-row--total dd, +.wd-balance-total { + font-size: 0.88rem; + color: var(--se-accent); +} + +.wd-depth-body { + padding: 6px 8px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.wd-depth-side--ask { margin-bottom: 2px; } + +.wd-depth-mid { + text-align: center; + font-size: 0.82rem; + font-weight: 700; + padding: 4px; + background: color-mix(in srgb, var(--bg2) 70%, transparent); + border-radius: 4px; +} + +.wd-depth-row { + display: grid; + grid-template-columns: 72px 1fr 56px; + align-items: center; + gap: 4px; + font-size: 0.62rem; + margin-bottom: 2px; +} + +.wd-depth-bar-wrap { + height: 10px; + background: color-mix(in srgb, var(--bg2) 80%, transparent); + border-radius: 2px; + overflow: hidden; +} + +.wd-depth-bar { + height: 100%; + border-radius: 2px; +} + +.wd-depth-bar--ask { background: color-mix(in srgb, #3498db 55%, transparent); } +.wd-depth-bar--bid { background: color-mix(in srgb, #e74c3c 55%, transparent); } + +.wd-depth-price { text-align: right; } +.wd-depth-size { text-align: right; color: var(--se-text-muted); } + +.wd-mini-chart-wrap { + flex: 1; + min-height: 160px; + padding: 4px; +} + +.wd-widget-host--mini-chart .paper-mini-chart { + height: 100%; +} + +.wd-picker-section { + margin: 0; +} + +.wd-picker-section-title { + margin: 0 0 8px; + font-size: 0.72rem; + font-weight: 700; + color: var(--se-accent, var(--accent)); + letter-spacing: 0.02em; +} + +.wd-strategy-list { + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px; +} + +.wd-strategy-item { + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + text-align: left; + cursor: pointer; + color: var(--se-text, var(--text)); +} + +.wd-strategy-item--on { + border-color: color-mix(in srgb, var(--se-accent) 45%, transparent); + background: color-mix(in srgb, var(--se-accent) 12%, transparent); +} + +/* ── 화면 구성 / 위젯 선택 팝업 (설정 화면 stg-* · AppPopup 연동) ── */ +.wd-layout-popup-body, +.wd-picker-popup-body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 16px 20px 20px; + display: flex; + flex-direction: column; + gap: 14px; + background: var(--bg, #16161e); +} + +.wd-layout-intro { + margin: 0; + padding: 10px 14px; + border-radius: 8px; + font-size: 12px; + line-height: 1.55; + color: var(--text2, #9aa5ce); + background: color-mix(in srgb, var(--accent, #3f7ef5) 6%, var(--bg2)); + border: 1px solid color-mix(in srgb, var(--accent) 18%, var(--border)); +} + +.wd-layout-section { + margin: 0; +} + +.wd-layout-mode-row { + grid-template-columns: repeat(3, 1fr); +} + +@media (max-width: 560px) { + .wd-layout-mode-row { + grid-template-columns: 1fr; + } +} + +.wd-layout-tab-row { + padding-top: 10px; + padding-bottom: 10px; +} + +.wd-layout-tab-fields { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 10px 14px; + width: 100%; +} + +.wd-layout-tab-field { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 120px; +} + +.wd-layout-tab-field-label { + font-size: 11px; + font-weight: 600; + color: var(--text3); +} + +.wd-layout-tab-delete { + flex-shrink: 0; + align-self: flex-end; + padding: 6px 12px; + font-size: 12px; +} + +.wd-layout-tab-add { + padding: 0 16px 12px; +} + +.wd-layout-tab-add-btn { + padding: 7px 14px; + font-size: 12px; + border-radius: 6px; +} + +.wd-picker-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 10px; +} + +.wd-picker-item { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg3); + cursor: pointer; + text-align: left; + color: var(--text); + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; +} + +.wd-picker-item:hover { + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); + background: color-mix(in srgb, var(--bg3) 80%, var(--accent) 20%); + box-shadow: 0 2px 8px color-mix(in srgb, var(--accent) 12%, transparent); +} + +.wd-picker-item-label { + font-size: 13px; + font-weight: 600; + color: var(--text); + line-height: 1.35; +} + +.wd-picker-item-desc { + font-size: 11px; + color: var(--text3); + line-height: 1.45; +} + +.bps-page--wd-no-left .bps-side-wrap--left, +.bps-page--wd-no-left .bps-side-wrap--left + .bps-splitter { + display: none !important; +} + +.bps-page--wd-no-right .bps-side-wrap--right, +.bps-page--wd-no-right .bps-main-row > .bps-splitter--v:last-of-type { + display: none !important; +} + +.bps-page--wd .bps-left-body, +.bps-page--wd .bps-right-body, +.bps-page--wd .bps-footer-body { + min-height: 0; + display: flex; + flex-direction: column; +} + +/* ── 영역 fit: shell 여백·이중 스크롤 제거 ── */ +.bps-page--wd .bps-main { + padding-bottom: 0; +} + +.bps-page--wd .bps-center-content { + padding: 0; + overflow: hidden; +} + +.bps-page--wd .bps-footer { + margin: 0; + border-radius: 0; + border-left: none; + border-right: none; + border-bottom: none; + min-height: 0; +} + +.bps-page--wd .bps-footer-body { + padding: 0; + overflow: hidden; +} + +.bps-page--wd .bps-left .bps-panel-head, +.bps-page--wd .bps-right .bps-panel-head { + display: none; +} + +.bps-page--wd .bps-panel-body > .wd-zone, +.bps-page--wd .bps-center-content > .wd-zone { + padding: 0; +} + +.bps-page--wd .wd-zone-slots { + padding: 0; + gap: 1px; +} + +.bps-page--wd .wd-zone-slots--1 { + gap: 0; +} + +/* 차트 캔버스 — 슬롯 높이에 맞춤 (고정 vh 제거) */ +.bps-page--wd .wd-widget-host .vtd-card-chart-canvas, +.bps-page--wd .wd-widget-host .slot-chart-wrap { + flex: 1; + min-height: 0 !important; + height: auto !important; + max-height: none; + border-radius: 0; + border-left: none; + border-right: none; +} + +.bps-page--wd .wd-widget-host .wd-candle-chart-canvas { + min-height: 0 !important; +} + +.bps-page--wd .wd-candle-chart-main .tv-drawing-sidebar { + align-self: stretch; + max-height: none; +} + +.bps-page--wd .wd-widget-host--chart .ptd-analysis-chart { + flex: 1; + min-height: 0; + height: 100%; +} + +.bps-page--wd .wd-widget-host--chart .ptd-analysis-chart .ptd-chart-canvas { + max-height: none; + min-height: 0; + flex: 1; +} + +/* 미니 차트 */ +.bps-page--wd .wd-mini-chart-wrap { + min-height: 0; + flex: 1; + display: flex; + flex-direction: column; + padding: 0; +} + +.bps-page--wd .wd-widget-host--mini-chart .ptd-chart-wrap { + flex: 1; + min-height: 0; + padding: 4px 8px 0; + display: flex; + flex-direction: column; +} + +.bps-page--wd .wd-widget-host--mini-chart .ptd-chart-head, +.bps-page--wd .wd-widget-host--mini-chart .ptd-tf-row { + flex-shrink: 0; +} + +.bps-page--wd .wd-widget-host--mini-chart .ptd-chart-canvas { + flex: 1; + min-height: 0 !important; + max-height: none !important; +} + +/* 호가·매매 — 내부 스크롤만 */ +.bps-page--wd .wd-widget-host--trade, +.bps-page--wd .wd-widget-host--orderbook { + overflow: hidden; +} + +.bps-page--wd .wd-widget-host--trade > *, +.bps-page--wd .wd-widget-host--orderbook > * { + height: 100%; + overflow: auto; + flex: 1; + min-height: 0; +} + +/* 리스트형 위젯 */ +.bps-page--wd .wd-widget-host--history, +.bps-page--wd .wd-widget-host--kpi, +.bps-page--wd .wd-widget-host--notifications, +.bps-page--wd .wd-widget-host--strategy-list, +.bps-page--wd .wd-widget-host--backtest-list { + overflow: hidden; +} + +.bps-page--wd .wd-widget-host--history .ptd-trade-history--fill { + padding: 0; + flex: 1; +} + +.bps-page--wd .wd-widget-host--history .ptd-trade-history-scroll { + flex: 1; + min-height: 0; +} + +.bps-page--wd .wd-widget-host--tape { + padding: 0; + min-height: 0; +} diff --git a/frontend/src/types/floatingWidget.ts b/frontend/src/types/floatingWidget.ts new file mode 100644 index 0000000..9e267b6 --- /dev/null +++ b/frontend/src/types/floatingWidget.ts @@ -0,0 +1,80 @@ +/** 플로팅(팝업) 위젯 창 — 메뉴바에서 다중 실행 */ + +import { newWidgetId, newWidgetSlot, type WidgetSlot } from './widgetDashboard'; + +export interface FloatingWidgetLayoutPreset { + id: string; + rows: number; + cols: number; + label: string; +} + +export const FLOATING_WIDGET_LAYOUTS: FloatingWidgetLayoutPreset[] = [ + { id: '1x1', rows: 1, cols: 1, label: '1×1' }, + { id: '1x2', rows: 1, cols: 2, label: '1×2' }, + { id: '2x1', rows: 2, cols: 1, label: '2×1' }, + { id: '2x2', rows: 2, cols: 2, label: '2×2' }, + { id: '1x3', rows: 1, cols: 3, label: '1×3' }, + { id: '3x1', rows: 3, cols: 1, label: '3×1' }, + { id: '2x3', rows: 2, cols: 3, label: '2×3' }, + { id: '3x2', rows: 3, cols: 2, label: '3×2' }, + { id: '3x3', rows: 3, cols: 3, label: '3×3' }, +]; + +export interface FloatingWidgetInstance { + id: string; + rows: number; + cols: number; + slots: WidgetSlot[]; + position: { x: number; y: number }; + width: number; + height: number; + zIndex: number; +} + +export function defaultSizeForGrid(rows: number, cols: number): { width: number; height: number } { + const cellW = 280; + const cellH = 220; + const chromeW = 16; + const chromeH = 44; + return { + width: Math.min(cols * cellW + chromeW, window.innerWidth - 40), + height: Math.min(rows * cellH + chromeH, window.innerHeight - 80), + }; +} + +/** 그리드 칸당 최소 크기 — 리사이즈 하한 */ +export function minSizeForGrid(rows: number, cols: number): { width: number; height: number } { + const cellMinW = 140; + const cellMinH = 100; + const chromeW = 16; + const chromeH = 44; + return { + width: cols * cellMinW + chromeW, + height: rows * cellMinH + chromeH, + }; +} + +export function createFloatingWidgetInstance( + rows: number, + cols: number, + index: number, + baseZIndex: number, +): FloatingWidgetInstance { + const count = rows * cols; + const { width, height } = defaultSizeForGrid(rows, cols); + const offset = 28 * index; + return { + id: newWidgetId('fw'), + rows, + cols, + slots: Array.from({ length: count }, () => newWidgetSlot()), + position: { + x: Math.min(80 + offset, Math.max(24, window.innerWidth - width - 24)), + y: Math.min(72 + offset, Math.max(24, window.innerHeight - height - 24)), + }, + width, + height, + zIndex: baseZIndex + index, + }; +} diff --git a/frontend/src/types/uiPreferences.ts b/frontend/src/types/uiPreferences.ts index 03e67e1..f07e55d 100644 --- a/frontend/src/types/uiPreferences.ts +++ b/frontend/src/types/uiPreferences.ts @@ -5,6 +5,7 @@ import type { VirtualCardViewMode, VirtualSessionConfig, VirtualTargetItem } fro import type { StrategyEditorMode } from '../utils/strategyEditorModeStorage'; import type { UserStrategyTemplate } from '../utils/strategyTemplateStorage'; import type { SidewaysFilterPaletteEntry } from '../utils/sidewaysFilterPaletteStorage'; +import type { WidgetLayoutSchema } from './widgetDashboard'; /** gc_app_settings.ui_preferences_json 구조 */ export interface UiPreferences { @@ -54,6 +55,8 @@ export interface UiPreferences { /** 시간봉 필터 — 빈 문자열=전체, 그 외 candleType (1m, 5m, …) */ candleFilter?: string; }; + /** 위젯 대시보드 — 단일 레이아웃 (기기 간 동기화) */ + widgetDashboard?: WidgetLayoutSchema; } export const UI_PREFERENCES_VERSION = 1; diff --git a/frontend/src/types/widgetDashboard.ts b/frontend/src/types/widgetDashboard.ts new file mode 100644 index 0000000..59dea81 --- /dev/null +++ b/frontend/src/types/widgetDashboard.ts @@ -0,0 +1,166 @@ +/** 위젯 대시보드 레이아웃 스키마 (uiPreferences.widgetDashboard) */ + +export type WidgetZoneId = 'left' | 'center' | 'right' | 'bottom'; + +export type WidgetZoneMode = 'single' | 'split' | 'tabs'; + +export type WidgetCategory = 'center' | 'side'; + +export interface WidgetSlot { + id: string; + widgetType: string | null; + config?: Record; +} + +export interface WidgetTabConfig { + id: string; + label: string; + /** 탭 내부 split 슬롯 (1~3) */ + splitCount: 1 | 2 | 3; + slots: WidgetSlot[]; +} + +export interface WidgetZoneConfig { + mode: WidgetZoneMode; + /** split 모드 — 상하 카드 분할 수 */ + splitCount?: 1 | 2 | 3; + slots?: WidgetSlot[]; + tabs?: WidgetTabConfig[]; +} + +export interface WidgetLayoutSchema { + v: 1; + zones: { + left: WidgetZoneConfig | null; + center: WidgetZoneConfig; + right: WidgetZoneConfig | null; + bottom: WidgetZoneConfig | null; + }; + collapsed: { + left?: boolean; + right?: boolean; + bottom?: boolean; + }; + panelSizes: { + leftWidth?: number; + rightWidth?: number; + footerHeight?: number; + }; +} + +export function newWidgetId(prefix = 'w'): string { + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; +} + +export function newWidgetSlot(widgetType: string | null = null): WidgetSlot { + return { id: newWidgetId('slot'), widgetType, config: {} }; +} + +export function slotsForSplitCount(count: 1 | 2 | 3, defaults: (string | null)[]): WidgetSlot[] { + return Array.from({ length: count }, (_, i) => + newWidgetSlot(defaults[i] ?? null), + ); +} + +export function newWidgetTab(label: string, splitCount: 1 | 2 | 3 = 1, defaults: (string | null)[] = []): WidgetTabConfig { + return { + id: newWidgetId('tab'), + label, + splitCount, + slots: slotsForSplitCount(splitCount, defaults), + }; +} + +export function defaultWidgetLayout(): WidgetLayoutSchema { + return { + v: 1, + zones: { + left: { + mode: 'single', + splitCount: 1, + slots: [newWidgetSlot('market.list')], + }, + center: { + mode: 'single', + splitCount: 1, + slots: [newWidgetSlot('chart.candle')], + }, + right: { + mode: 'split', + splitCount: 2, + slots: slotsForSplitCount(2, ['trade.buySell', 'trade.orderbook']), + }, + bottom: { + mode: 'single', + splitCount: 1, + slots: [newWidgetSlot('trade.history')], + }, + }, + collapsed: {}, + panelSizes: {}, + }; +} + +/** splitCount 변경 시 슬롯 수 맞춤 */ +export function normalizeZoneSlots(zone: WidgetZoneConfig): WidgetZoneConfig { + if (zone.mode === 'tabs') { + return { + ...zone, + tabs: (zone.tabs ?? []).map(tab => { + const count = tab.splitCount ?? 1; + const slots = tab.slots ?? []; + if (slots.length === count) return tab; + if (slots.length > count) return { ...tab, slots: slots.slice(0, count) }; + return { + ...tab, + slots: [ + ...slots, + ...Array.from({ length: count - slots.length }, () => newWidgetSlot()), + ], + }; + }), + }; + } + const count = zone.splitCount ?? 1; + const slots = zone.slots ?? []; + if (slots.length === count) return zone; + if (slots.length > count) return { ...zone, slots: slots.slice(0, count) }; + return { + ...zone, + slots: [ + ...slots, + ...Array.from({ length: count - slots.length }, () => newWidgetSlot()), + ], + }; +} + +export function normalizeWidgetLayout(raw: unknown): WidgetLayoutSchema { + const base = defaultWidgetLayout(); + if (!raw || typeof raw !== 'object') return base; + const o = raw as Partial; + const zonesRaw = (o.zones ?? {}) as Partial; + const zones = zonesRaw; + const normZone = (z: WidgetZoneConfig | null | undefined, fallback: WidgetZoneConfig | null): WidgetZoneConfig | null => { + if (z === null) return null; + if (!z) return fallback; + return normalizeZoneSlots(z); + }; + return { + v: 1, + zones: { + left: normZone(zones.left as WidgetZoneConfig | null | undefined, base.zones.left), + center: normZone(zones.center as WidgetZoneConfig | undefined, base.zones.center)!, + right: normZone(zones.right as WidgetZoneConfig | null | undefined, base.zones.right), + bottom: normZone(zones.bottom as WidgetZoneConfig | null | undefined, base.zones.bottom), + }, + collapsed: { ...base.collapsed, ...(o.collapsed ?? {}) }, + panelSizes: { ...base.panelSizes, ...(o.panelSizes ?? {}) }, + }; +} + +export function zoneSlotCount(zone: WidgetZoneConfig): number { + if (zone.mode === 'tabs') { + return (zone.tabs ?? []).reduce((n, t) => n + (t.slots?.length ?? t.splitCount ?? 1), 0); + } + return zone.slots?.length ?? zone.splitCount ?? 1; +} diff --git a/frontend/src/utils/permissions.ts b/frontend/src/utils/permissions.ts index 1decd02..901c2f6 100644 --- a/frontend/src/utils/permissions.ts +++ b/frontend/src/utils/permissions.ts @@ -12,7 +12,7 @@ export type SettingsCategoryId = | 'trading' | 'paper' | 'virtual' | 'live' | 'trend-search' | 'alert' | 'network' | 'admin'; export const TOP_MENU_IDS: TopMenuId[] = [ - 'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy-editor', 'strategy-evaluation', 'backtest', 'analysis-report', 'notifications', 'settings', 'verification-board', + 'dashboard', 'widget-dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy-editor', 'strategy-evaluation', 'backtest', 'analysis-report', 'notifications', 'settings', 'verification-board', ]; export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [ @@ -21,7 +21,7 @@ export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [ ]; export const ALL_MENU_IDS = [ - 'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'verification-board', 'strategy', 'strategy-editor', 'strategy-evaluation', 'backtest', 'analysis-report', 'notifications', 'settings', + 'dashboard', 'widget-dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'verification-board', 'strategy', 'strategy-editor', 'strategy-evaluation', 'backtest', 'analysis-report', 'notifications', 'settings', ...SETTINGS_MENU_IDS.map(id => `settings_${id}` as const), ] as const; @@ -29,6 +29,7 @@ export type MenuPermissionId = typeof ALL_MENU_IDS[number]; export const MENU_LABELS: Record = { dashboard: '대시보드', + 'widget-dashboard': '위젯', chart: '실시간차트', paper: '투자관리', virtual: '가상매매', diff --git a/frontend/src/utils/uiPreferencesDb.ts b/frontend/src/utils/uiPreferencesDb.ts index cc31af4..3a2a561 100644 --- a/frontend/src/utils/uiPreferencesDb.ts +++ b/frontend/src/utils/uiPreferencesDb.ts @@ -3,6 +3,7 @@ */ import { getAppSettingsCache, + isAppSettingsCacheLoaded, patchAppSettingsCache, subscribeAppSettings, } from '../hooks/useAppSettings'; @@ -12,6 +13,7 @@ import { UI_PREFERENCES_VERSION, type UiPreferences, } from '../types/uiPreferences'; +import { normalizeWidgetLayout } from '../types/widgetDashboard'; const SAVE_DEBOUNCE_MS = 400; let saveTimer: ReturnType | null = null; @@ -51,6 +53,9 @@ export function resolveUiPreferences(raw: unknown): UiPreferences { panels: { ...EMPTY_UI_PREFERENCES.panels, ...o.panels }, virtual: { ...EMPTY_UI_PREFERENCES.virtual, ...o.virtual }, tradeNotifications: { ...EMPTY_UI_PREFERENCES.tradeNotifications, ...o.tradeNotifications }, + widgetDashboard: o.widgetDashboard + ? normalizeWidgetLayout(o.widgetDashboard) + : undefined, }; } @@ -61,6 +66,13 @@ export function getUiPreferences(): UiPreferences { /** UI 설정 부분 갱신 — 낙관적 캐시 + 디바운스 DB 저장 */ export function patchUiPreferences(patch: Partial, immediate = false): void { + if (!isAppSettingsCacheLoaded()) { + pendingPatch = pendingPatch + ? (deepMerge(pendingPatch as Record, patch as Record) as Partial) + : patch; + return; + } + const current = getUiPreferences(); const next = deepMerge(current as Record, patch as Record) as UiPreferences; next.v = UI_PREFERENCES_VERSION; @@ -73,6 +85,7 @@ export function patchUiPreferences(patch: Partial, immediate = fa const flush = () => { saveTimer = null; + if (!isAppSettingsCacheLoaded()) return; const toSave = pendingPatch ? resolveUiPreferences(deepMerge(getUiPreferences() as Record, pendingPatch as Record)) : next; @@ -95,10 +108,36 @@ export function patchUiPreferences(patch: Partial, immediate = fa saveTimer = setTimeout(flush, SAVE_DEBOUNCE_MS); } +/** 디바운스 대기 중인 UI 설정 즉시 DB 저장 (페이지 이탈 시) */ +export function flushPendingUiPreferences(): void { + if (saveTimer != null) { + clearTimeout(saveTimer); + saveTimer = null; + } + if (!isAppSettingsCacheLoaded() || !pendingPatch) return; + const toSave = resolveUiPreferences( + deepMerge(getUiPreferences() as Record, pendingPatch as Record), + ); + pendingPatch = null; + void saveAppSettings({ uiPreferences: toSave }).catch(err => { + console.warn('[uiPreferences] DB flush 저장 실패:', err); + }); +} + export function subscribeUiPreferences(listener: () => void): () => void { return subscribeAppSettings(listener); } +/** 앱 설정 DB 로드 직후 — 로드 전에 큐잉된 patch 병합 */ +export function reconcilePendingUiPreferencesOnLoad(): void { + if (!isAppSettingsCacheLoaded() || !pendingPatch) return; + const merged = resolveUiPreferences( + deepMerge(getUiPreferences() as Record, pendingPatch as Record), + ); + patchAppSettingsCache({ uiPreferences: merged }); + pendingPatch = null; +} + /** 중첩 경로 헬퍼 */ export function getUiPref(key: K): UiPreferences[K] { return getUiPreferences()[key]; diff --git a/frontend/src/widgets/WidgetCandleChartPanel.tsx b/frontend/src/widgets/WidgetCandleChartPanel.tsx new file mode 100644 index 0000000..c910aa5 --- /dev/null +++ b/frontend/src/widgets/WidgetCandleChartPanel.tsx @@ -0,0 +1,594 @@ +/** + * 위젯 대시보드 — 종목 검색 + 좌·우 세로 툴바 + 실시간 캔들 차트 + */ +import React, { + useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, +} from 'react'; +import ReactDOM from 'react-dom'; +import TradingChart from '../components/TradingChart'; +import DrawingToolbar from '../components/DrawingToolbar'; +import { MarketSearchPanel } from '../components/MarketSearchPanel'; +import IndicatorDropdownPanel from '../components/IndicatorDropdownPanel'; +import type { StrategyDto } from '../utils/backendApi'; +import { pinCandleWatch } from '../utils/backendApi'; +import type { + ChartMode, ChartType, Drawing, IndicatorConfig, LegendData, OHLCVBar, Theme, Timeframe, +} from '../types'; +import { isUpbitMarket } from '../utils/upbitApi'; +import { useChartRealtimeData, type ChartRealtimeSource } from '../hooks/useChartRealtimeData'; +import { useHistoryLoader } from '../hooks/useHistoryLoader'; +import { useIndicatorSettings } from '../hooks/useIndicatorSettings'; +import type { ChartManager } from '../utils/ChartManager'; +import { + buildVirtualTradingChartIndicators, + resolveStrategyPrimaryTimeframe, + resolveStrategyTimeframes, +} from '../utils/strategyToChartIndicators'; +import { timeframeToCandleType } from '../utils/chartCandleType'; +import { DISPLAY_COUNT } from '../hooks/useUpbitData'; +import { getKoreanName } from '../utils/marketNameCache'; +import { + DEFAULT_CHART_OVERLAY_VISIBILITY, + listChartOverlayToggleItems, + chartOverlayKeyForType, + type ChartOverlayToggleKey, + type ChartOverlayVisibility, +} from '../utils/chartOverlayVisibility'; +import { + duplicateIndicatorInList, + mergeIndicators, + removeIndicatorFromList, + removeIndicatorTypeFromList, + replaceIndicatorConfigsFromTypes, + reorderIndicatorGroup, + splitIndicatorPane, +} from '../utils/indicatorPaneMerge'; +import { getIndicatorDef, type IndicatorDef } from '../utils/indicatorRegistry'; +import { sortIndicatorsByTypeOrder } from '../utils/indicatorListOrder'; +import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from '../utils/smaConfig'; +import { normalizeIchimokuConfig } from '../utils/ichimokuConfig'; + +let _widgetIndCounter = 0; +function newIndId() { return `wind_${++_widgetIndCounter}_${Date.now()}`; } + +const COMPACT_MIN_BAR_PX = 5; + +function resolveCompactDisplayCount(width: number): number { + if (width <= 0) return DISPLAY_COUNT; + return Math.max(40, Math.min(DISPLAY_COUNT, Math.floor(width / COMPACT_MIN_BAR_PX))); +} + +interface Props { + market: string; + onMarketChange: (market: string) => void; + strategy: StrategyDto | undefined; + theme?: Theme; + chartRealtimeSource?: ChartRealtimeSource; +} + +const noop = () => {}; + +const WidgetCandleChartPanel: React.FC = ({ + market, + onMarketChange, + strategy, + theme = 'dark', + chartRealtimeSource = 'BACKEND_STOMP', +}) => { + const { getParams, getVisualConfig } = useIndicatorSettings(); + const tfOptions = useMemo(() => resolveStrategyTimeframes(strategy), [strategy]); + const [timeframe, setTimeframe] = useState( + () => resolveStrategyPrimaryTimeframe(strategy), + ); + const [mode, setMode] = useState('chart'); + const [drawingTool, setDrawingTool] = useState('cursor'); + const [drawings, setDrawings] = useState([]); + 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 [showMarketSearch, setShowMarketSearch] = useState(false); + const [showIndicatorPanel, setShowIndicatorPanel] = useState(false); + const [leftToolbarVisible, setLeftToolbarVisible] = useState(true); + const [rightToolbarVisible, setRightToolbarVisible] = useState(true); + const [focusedIndicatorId, setFocusedIndicatorId] = useState(null); + const [overlayVisibility, setOverlayVisibility] = useState( + () => ({ ...DEFAULT_CHART_OVERLAY_VISIBILITY }), + ); + + const marketBtnRef = useRef(null); + const managerRef = useRef(null); + const canvasWrapRef = useRef(null); + const indicatorsRef = useRef([]); + const [chartReloadTick, setChartReloadTick] = useState(0); + const chartReloadTriggeredRef = useRef(false); + const pendingBarRef = useRef(null); + const barsMarketRef = useRef(null); + const chartLiveReadyRef = useRef(false); + const marketRef = useRef(market); + marketRef.current = market; + + useEffect(() => { + setTimeframe(resolveStrategyPrimaryTimeframe(strategy)); + }, [strategy, market]); + + const baseIndicators = useMemo( + () => buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig), + [strategy, getParams, getVisualConfig], + ); + + const [indicators, setIndicators] = useState(baseIndicators); + useEffect(() => { + setIndicators(prev => { + const baseTypes = new Set(baseIndicators.map(i => i.type)); + const userExtras = prev.filter(i => !baseTypes.has(i.type)); + return sortIndicatorsByTypeOrder([...baseIndicators, ...userExtras]); + }); + }, [baseIndicators]); + + indicatorsRef.current = indicators; + + const effectiveIndicators = useMemo(() => { + const tfFiltered = indicators.filter(ind => + ind.timeframeVisibility?.[timeframe] !== false, + ); + if (!focusedIndicatorId) return tfFiltered; + return tfFiltered.filter(ind => { + const def = getIndicatorDef(ind.type); + return def?.overlay || def?.returnsMarkers || ind.id === focusedIndicatorId; + }); + }, [focusedIndicatorId, indicators, timeframe]); + + const candleOverlayToggles = useMemo( + () => listChartOverlayToggleItems(overlayVisibility), + [overlayVisibility], + ); + + const resolveInitialDisplayCount = useCallback( + (plotWidth: number) => resolveCompactDisplayCount(plotWidth), + [], + ); + + const syncChartLayout = useCallback(() => { + const wrap = canvasWrapRef.current; + const mgr = managerRef.current; + if (!wrap || !mgr?.hasMainSeries()) return; + const chartWrap = wrap.querySelector('.tv-chart-wrap'); + const w = chartWrap?.clientWidth ?? wrap.clientWidth; + const h = chartWrap?.clientHeight ?? wrap.clientHeight; + if (w <= 0 || h <= 0) return; + mgr.syncLayout(w, h); + }, []); + + const handleCandlesReady = useCallback(() => { + chartLiveReadyRef.current = true; + const pending = pendingBarRef.current; + const mgr = managerRef.current; + if (pending && mgr && barsMarketRef.current === marketRef.current) { + pendingBarRef.current = null; + mgr.updateBar(pending); + } + syncChartLayout(); + }, [syncChartLayout]); + + const applyRealtimeBar = useCallback((bar: OHLCVBar, append: boolean) => { + if (barsMarketRef.current !== marketRef.current) return; + if (!chartLiveReadyRef.current || !managerRef.current) { + pendingBarRef.current = bar; + return; + } + if (append) managerRef.current.appendBar(bar); + else managerRef.current.updateBar(bar); + }, []); + + const handleTickUpdate = useCallback((bar: OHLCVBar) => { + applyRealtimeBar(bar, false); + }, [applyRealtimeBar]); + + const handleNewCandle = useCallback((bar: OHLCVBar) => { + applyRealtimeBar(bar, true); + }, [applyRealtimeBar]); + + const useUpbit = isUpbitMarket(market); + + const { bars, barsMarket, isLoading } = useChartRealtimeData( + market, + timeframe, + useMemo(() => ({ + onTickUpdate: handleTickUpdate, + onNewCandle: handleNewCandle, + enabled: useUpbit, + source: chartRealtimeSource, + }), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]), + ); + + const { isLoadingMore } = useHistoryLoader({ + symbol: market, + timeframe, + isUpbit: useUpbit, + managerRef, + }); + + barsMarketRef.current = barsMarket; + const barsReady = bars.length >= 2 && barsMarket === market; + + const requestChartReload = useCallback(() => { + chartReloadTriggeredRef.current = true; + setChartReloadTick(t => t + 1); + }, []); + + useLayoutEffect(() => { + chartLiveReadyRef.current = false; + pendingBarRef.current = null; + chartReloadTriggeredRef.current = false; + }, [market, timeframe]); + + useEffect(() => { + const wrap = canvasWrapRef.current; + if (!wrap) return; + let resizeTimer: ReturnType | null = null; + const scheduleLayoutSync = () => { + if (resizeTimer) clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => syncChartLayout(), 120); + }; + const ro = new ResizeObserver(() => scheduleLayoutSync()); + ro.observe(wrap); + return () => { + if (resizeTimer) clearTimeout(resizeTimer); + ro.disconnect(); + }; + }, [market, timeframe, syncChartLayout]); + + useEffect(() => { + const id = requestAnimationFrame(() => syncChartLayout()); + return () => cancelAnimationFrame(id); + }, [leftToolbarVisible, rightToolbarVisible, syncChartLayout]); + + useEffect(() => { + if (!barsReady) return; + if (!managerRef.current?.hasMainSeries() && !chartReloadTriggeredRef.current) { + requestChartReload(); + } + }, [barsReady, requestChartReload]); + + useEffect(() => { + if (!useUpbit) return; + void pinCandleWatch(market, timeframeToCandleType(timeframe)); + if (timeframeToCandleType(timeframe) !== '1m') { + void pinCandleWatch(market, '1m'); + } + }, [market, timeframe, useUpbit]); + + useEffect(() => { + managerRef.current?.setCrosshairMagnet(magnetMode !== 'off'); + }, [magnetMode]); + + const handleToggleHidden = useCallback((id: string) => { + setIndicators(prev => prev.map(i => (i.id === id ? { ...i, hidden: !i.hidden } : i))); + }, []); + + const handleRemoveIndicator = useCallback((id: string) => { + setIndicators(prev => removeIndicatorFromList(prev, id)); + setFocusedIndicatorId(prev => (prev === id ? null : prev)); + }, []); + + const buildIndicatorConfig = useCallback(( + def: IndicatorDef, + paramOverrides?: Record, + ): IndicatorConfig => { + const params = { ...getParams(def.type, def.defaultParams), ...paramOverrides }; + const { plots, hlines, cloudColors, bandBackground } = getVisualConfig(def.type, def.plots, def.hlines); + let cfg: IndicatorConfig = { + id: newIndId(), + type: def.type, + params, + plots, + hlines, + ...(cloudColors ? { cloudColors } : {}), + ...(bandBackground ? { bandBackground } : {}), + }; + if (def.type === 'SMA') { + cfg = normalizeSmaConfig({ + ...cfg, + plotVisibility: cfg.plotVisibility ?? createDefaultSmaPlotVisibility(), + }); + } else if (def.type === 'IchimokuCloud') { + cfg = normalizeIchimokuConfig(cfg); + } + return cfg; + }, [getParams, getVisualConfig]); + + const handleAddIndicator = useCallback((type: string) => { + setIndicators(prev => { + if (prev.some(i => i.type === type)) return prev; + const def = getIndicatorDef(type); + if (!def) return prev; + return sortIndicatorsByTypeOrder([...prev, buildIndicatorConfig(def)]); + }); + }, [buildIndicatorConfig]); + + const handleAddIndicators = useCallback((types: string[]) => { + if (!types.length) return; + const orderedTypes = types.filter(t => getIndicatorDef(t)); + if (orderedTypes.length === 0) return; + setIndicators( + sortIndicatorsByTypeOrder( + replaceIndicatorConfigsFromTypes(orderedTypes, getParams, getVisualConfig, newIndId), + orderedTypes, + ), + ); + }, [getParams, getVisualConfig]); + + const handleIndicatorListOrderChange = useCallback((orderedTypes: string[]) => { + setIndicators(prev => sortIndicatorsByTypeOrder(prev, orderedTypes)); + }, []); + + const handleRemoveIndicatorByType = useCallback((type: string) => { + setIndicators(prev => removeIndicatorTypeFromList(prev, type)); + setFocusedIndicatorId(prev => { + if (!prev) return prev; + const focused = indicatorsRef.current.find(i => i.id === prev); + return focused?.type === type ? null : prev; + }); + }, []); + + const handleReorderIndicators = useCallback((fromId: string, insertBeforeId: string | null) => { + setIndicators(prev => reorderIndicatorGroup(fromId, insertBeforeId, prev)); + }, []); + + const handleMergeIndicators = useCallback((fromId: string, intoId: string) => { + setIndicators(prev => mergeIndicators(fromId, intoId, prev)); + }, []); + + const handleSplitIndicatorPane = useCallback((hostId: string) => { + setIndicators(prev => splitIndicatorPane(hostId, prev)); + }, []); + + const handleExpandIndicator = useCallback((id: string) => { + setFocusedIndicatorId(id); + }, []); + + const handleRestoreIndicators = useCallback(() => { + setFocusedIndicatorId(null); + }, []); + + const handleDuplicateIndicator = useCallback((sourceId: string) => { + setIndicators(prev => duplicateIndicatorInList(sourceId, prev, newIndId) ?? prev); + }, []); + + 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 handleAddDrawing = useCallback((d: Drawing) => { + setDrawings(prev => [...prev, d]); + }, []); + + const handleClearDrawings = useCallback(() => { + setDrawings([]); + managerRef.current?.clearDrawings(); + }, []); + + return ( +
+
+ + + {tfOptions.length > 1 && ( +
+ {tfOptions.map(tf => ( + + ))} +
+ )} + +
+
+ + +
+ + + + +
+
+ +
+ {leftToolbarVisible && ( + setDrawings(prev => prev.slice(0, -1))} + locked={drawingsLocked} + visible={drawingsVisible} + onToggleLock={() => setDrawingsLocked(v => !v)} + onToggleEye={() => setDrawingsVisible(v => !v)} + indicatorsVisible={indicatorsVisible} + magnetMode={magnetMode} + snapToIndicator={snapToIndicator} + drawingCount={drawings.length} + indicatorCount={indicators.length} + onMagnetMode={setMagnetMode} + onSnapToIndicator={setSnapToIndicator} + onHideDrawings={() => setDrawingsVisible(v => !v)} + onHideIndicators={() => setIndicatorsVisible(v => !v)} + onHideAll={() => { setDrawingsVisible(false); setIndicatorsVisible(false); }} + onClearIndicators={() => setIndicators([])} + /> + )} + +
+ {isLoading &&
차트 로딩…
} + {isLoadingMore && ( +
+
+ 과거 데이터 로딩… +
+ )} + {!isLoading && !barsReady && ( +
캔들 데이터 수집 중…
+ )} + i.id).join(',')}`} + chartVisible + bars={bars} + barsMarket={barsMarket} + market={market} + timeframe={timeframe} + chartType={'candlestick' as ChartType} + theme={theme} + mode={mode} + indicators={effectiveIndicators} + drawingTool={drawingTool} + drawings={drawings} + logScale={false} + drawingsLocked={drawingsLocked} + drawingsVisible={drawingsVisible && mode === 'trading'} + onCrosshair={noop as (d: LegendData | null) => void} + onManagerReady={mgr => { + managerRef.current = mgr; + mgr.setChartOverlayVisibility(overlayVisibility); + }} + onAddDrawing={handleAddDrawing} + onCandlesReady={handleCandlesReady} + resolveInitialDisplayCount={resolveInitialDisplayCount} + skipViewportRecovery + magnifierEnabled={false} + volumeVisible={false} + paneLayoutClamp + showHoverToolbar={false} + showPaneLegend + showChartRightToolbar={rightToolbarVisible} + crosshairInfoVisible={false} + onReorderIndicators={handleReorderIndicators} + onMergeIndicators={handleMergeIndicators} + onSplitIndicatorPane={handleSplitIndicatorPane} + onExpandIndicator={handleExpandIndicator} + onRemoveIndicator={handleRemoveIndicator} + onDuplicateIndicator={handleDuplicateIndicator} + focusedIndicatorId={focusedIndicatorId} + onRestoreIndicators={handleRestoreIndicators} + onToggleIndicatorHidden={handleToggleHidden} + candleOverlayToggles={candleOverlayToggles} + onToggleCandleOverlay={handleToggleCandleOverlay} + /> +
+
+ + {showMarketSearch && marketBtnRef.current && ReactDOM.createPortal( + { + onMarketChange(m); + setShowMarketSearch(false); + }} + onClose={() => setShowMarketSearch(false)} + anchorRect={marketBtnRef.current.getBoundingClientRect()} + anchorPlacement="dropdown" + />, + document.body, + )} + + {showIndicatorPanel && ReactDOM.createPortal( + setShowIndicatorPanel(false)} + />, + document.body, + )} +
+ ); +}; + +export default WidgetCandleChartPanel; diff --git a/frontend/src/widgets/WidgetDashboardContext.tsx b/frontend/src/widgets/WidgetDashboardContext.tsx new file mode 100644 index 0000000..9bda3d2 --- /dev/null +++ b/frontend/src/widgets/WidgetDashboardContext.tsx @@ -0,0 +1,144 @@ +/** + * 위젯 대시보드 — 종목·매매 상태 공유 + */ +import React, { + createContext, useCallback, useContext, useEffect, useMemo, useState, +} from 'react'; +import type { PaperSummaryDto, PaperTradeDto, StrategyDto } from '../utils/backendApi'; +import type { TickerData, MarketInfo } from '../hooks/useMarketTicker'; +import type { TradeOrderFillRequest } from '../types'; +import type { Theme } from '../types'; +import type { ChartRealtimeSource } from '../hooks/useChartRealtimeData'; + +export interface WidgetDashboardContextValue { + theme: Theme; + selectedMarket: string; + setSelectedMarket: (m: string) => void; + tickers: Map; + marketInfos: MarketInfo[]; + marketLoading: boolean; + usdRate: number; + strategies: StrategyDto[]; + selectedStrategyId: number | null; + setSelectedStrategyId: (id: number | null) => void; + summary: PaperSummaryDto | null; + trades: PaperTradeDto[]; + refreshPaperData: () => void; + tradePrice: number | null; + fillBuy: TradeOrderFillRequest | null; + fillSell: TradeOrderFillRequest | null; + setFillBuy: (r: TradeOrderFillRequest | null) => void; + setFillSell: (r: TradeOrderFillRequest | null) => void; + paperTradingEnabled: boolean; + paperAutoTradeEnabled: boolean; + onPaperOrderFilled?: () => void; + chartRealtimeSource: ChartRealtimeSource; +} + +const WidgetDashboardContext = createContext(null); + +export function useWidgetDashboard(): WidgetDashboardContextValue { + const ctx = useContext(WidgetDashboardContext); + if (!ctx) throw new Error('useWidgetDashboard must be used within WidgetDashboardProvider'); + return ctx; +} + +export function useWidgetDashboardOptional(): WidgetDashboardContextValue | null { + return useContext(WidgetDashboardContext); +} + +interface ProviderProps { + theme: Theme; + tickers: Map; + marketInfos: MarketInfo[]; + marketLoading: boolean; + usdRate: number; + defaultMarket: string; + strategies: StrategyDto[]; + summary: PaperSummaryDto | null; + trades: PaperTradeDto[]; + refreshPaperData: () => void; + paperTradingEnabled: boolean; + paperAutoTradeEnabled: boolean; + onPaperOrderFilled?: () => void; + chartRealtimeSource: ChartRealtimeSource; + children: React.ReactNode; +} + +export const WidgetDashboardProvider: React.FC = ({ + theme, + tickers, + marketInfos, + marketLoading, + usdRate, + defaultMarket, + strategies, + summary, + trades, + refreshPaperData, + paperTradingEnabled, + paperAutoTradeEnabled, + onPaperOrderFilled, + chartRealtimeSource, + children, +}) => { + const [selectedMarket, setSelectedMarket] = useState(defaultMarket); + const [selectedStrategyId, setSelectedStrategyId] = useState(null); + + useEffect(() => { + if (defaultMarket) setSelectedMarket(defaultMarket); + }, [defaultMarket]); + const [fillBuy, setFillBuy] = useState(null); + const [fillSell, setFillSell] = useState(null); + + const tradePrice = useMemo(() => { + const t = tickers.get(selectedMarket); + return t?.tradePrice ?? null; + }, [tickers, selectedMarket]); + + const value = useMemo(() => ({ + theme, + selectedMarket, + setSelectedMarket, + tickers, + marketInfos, + marketLoading, + usdRate, + strategies, + selectedStrategyId, + setSelectedStrategyId, + summary, + trades, + refreshPaperData, + tradePrice, + fillBuy, + fillSell, + setFillBuy, + setFillSell, + paperTradingEnabled, + paperAutoTradeEnabled, + onPaperOrderFilled, + chartRealtimeSource, + }), [ + theme, selectedMarket, tickers, marketInfos, marketLoading, usdRate, + strategies, selectedStrategyId, summary, trades, refreshPaperData, + tradePrice, fillBuy, fillSell, paperTradingEnabled, paperAutoTradeEnabled, + onPaperOrderFilled, chartRealtimeSource, + ]); + + return ( + + {children} + + ); +}; + +export function useWidgetOrderbookFillHandler(): (price: number, rowType: 'ask' | 'bid') => void { + const ctx = useWidgetDashboard(); + return useCallback((price: number, rowType: 'ask' | 'bid') => { + const side = rowType === 'bid' ? 'buy' : 'sell'; + const req: TradeOrderFillRequest = { market: ctx.selectedMarket, price, side, seq: Date.now() }; + if (side === 'buy') ctx.setFillBuy(req); + else ctx.setFillSell(req); + }, [ctx]); +} diff --git a/frontend/src/widgets/hosts/AccountBalanceWidgetHost.tsx b/frontend/src/widgets/hosts/AccountBalanceWidgetHost.tsx new file mode 100644 index 0000000..4a5d60f --- /dev/null +++ b/frontend/src/widgets/hosts/AccountBalanceWidgetHost.tsx @@ -0,0 +1,52 @@ +import React, { useMemo } from 'react'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; +import WidgetPanelHeader from '../panels/WidgetPanelHeader'; + +const AccountBalanceWidgetHost: React.FC = () => { + const { summary } = useWidgetDashboard(); + + const rows = useMemo(() => { + if (!summary) return []; + return [ + { label: '총자산', value: summary.totalAsset, highlight: true }, + { label: '예수금', value: summary.cashBalance }, + { label: '주식평가', value: summary.stockEvalAmount }, + { label: '주문가능', value: summary.orderableCash ?? summary.cashBalance }, + { label: '평가손익', value: summary.unrealizedPnl, signed: true }, + { label: '실현손익', value: summary.realizedPnl, signed: true }, + { label: '총수익률', value: summary.totalReturnPct, pct: true }, + ]; + }, [summary]); + + return ( +
+ +
+ {!summary &&

계좌 정보를 불러오는 중…

} + {summary && ( +
+ {rows.map(row => { + let val = '-'; + if (row.pct) { + val = `${row.value >= 0 ? '+' : ''}${row.value.toFixed(2)}%`; + } else if (typeof row.value === 'number') { + val = `${row.signed && row.value >= 0 ? '+' : ''}${row.value.toLocaleString('ko-KR')}원`; + } + const cls = row.signed || row.pct + ? (row.value >= 0 ? 'wd-up' : 'wd-down') + : row.highlight ? 'wd-balance-total' : ''; + return ( +
+
{row.label}
+
{val}
+
+ ); + })} +
+ )} +
+
+ ); +}; + +export default AccountBalanceWidgetHost; diff --git a/frontend/src/widgets/hosts/BacktestListWidgetHost.tsx b/frontend/src/widgets/hosts/BacktestListWidgetHost.tsx new file mode 100644 index 0000000..4a23fae --- /dev/null +++ b/frontend/src/widgets/hosts/BacktestListWidgetHost.tsx @@ -0,0 +1,41 @@ +import React, { useEffect, useState } from 'react'; +import BacktestExecutionList from '../../components/backtest/BacktestExecutionList'; +import { loadBacktestResults, loadPaperTrades } from '../../utils/backendApi'; +import type { BacktestResultRecord } from '../../utils/backendApi'; +import { buildLiveExecutionItems } from '../../utils/liveExecutionGroups'; + +/** 백테스트·실매매 목록 — BacktestExecutionList 재사용 (축소) */ +const BacktestListWidgetHost: React.FC = () => { + const [tab, setTab] = useState<'backtest' | 'live'>('backtest'); + const [records, setRecords] = useState([]); + const [liveItems, setLiveItems] = useState>([]); + const [selectedBacktestId, setSelectedBacktestId] = useState(null); + const [selectedLiveId, setSelectedLiveId] = useState(null); + + useEffect(() => { + let cancelled = false; + void Promise.all([loadBacktestResults(), loadPaperTrades()]).then(([bt, trades]) => { + if (cancelled) return; + setRecords(bt ?? []); + setLiveItems(buildLiveExecutionItems(trades ?? [], null)); + }).catch(() => { /* ignore */ }); + return () => { cancelled = true; }; + }, []); + + return ( +
+ setSelectedBacktestId(r.id)} + onSelectLive={item => setSelectedLiveId(item.id)} + /> +
+ ); +}; + +export default BacktestListWidgetHost; diff --git a/frontend/src/widgets/hosts/BuySellWidgetHost.tsx b/frontend/src/widgets/hosts/BuySellWidgetHost.tsx new file mode 100644 index 0000000..79a308a --- /dev/null +++ b/frontend/src/widgets/hosts/BuySellWidgetHost.tsx @@ -0,0 +1,39 @@ +import React, { useMemo } from 'react'; +import TradeSplitOrderPanel from '../../components/trade/TradeSplitOrderPanel'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; + +/** 매수·매도 — TradeSplitOrderPanel 재사용 */ +const BuySellWidgetHost: React.FC = () => { + const { + selectedMarket, tradePrice, summary, fillBuy, fillSell, setSelectedMarket, + paperTradingEnabled, paperAutoTradeEnabled, onPaperOrderFilled, refreshPaperData, + } = useWidgetDashboard(); + + const availableKrw = summary?.orderableCash ?? summary?.cashBalance ?? 0; + const holding = useMemo( + () => summary?.positions?.find(h => h.symbol === selectedMarket), + [summary, selectedMarket], + ); + + return ( +
+ { + refreshPaperData(); + onPaperOrderFilled?.(); + }} + /> +
+ ); +}; + +export default BuySellWidgetHost; diff --git a/frontend/src/widgets/hosts/CandleChartWidgetHost.tsx b/frontend/src/widgets/hosts/CandleChartWidgetHost.tsx new file mode 100644 index 0000000..e2ed748 --- /dev/null +++ b/frontend/src/widgets/hosts/CandleChartWidgetHost.tsx @@ -0,0 +1,46 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { loadStrategyForNotification } from '../../utils/backendApi'; +import type { StrategyDto } from '../../utils/backendApi'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; +import WidgetCandleChartPanel from '../WidgetCandleChartPanel'; + +/** 실시간 캔들 차트 — 종목 검색 + 좌·우 세로 툴바 */ +const CandleChartWidgetHost: React.FC<{ config?: Record }> = ({ config }) => { + const { + theme, selectedMarket, setSelectedMarket, strategies, selectedStrategyId, chartRealtimeSource, + } = useWidgetDashboard(); + const [strategy, setStrategy] = useState(); + + const strategyId = useMemo(() => { + const fromConfig = config?.strategyId; + if (typeof fromConfig === 'number' && fromConfig > 0) return fromConfig; + if (selectedStrategyId != null && selectedStrategyId > 0) return selectedStrategyId; + return strategies[0]?.id ?? null; + }, [config?.strategyId, selectedStrategyId, strategies]); + + useEffect(() => { + if (!strategyId) { + setStrategy(undefined); + return; + } + let cancelled = false; + void loadStrategyForNotification(strategyId).then(s => { + if (!cancelled) setStrategy(s ?? undefined); + }); + return () => { cancelled = true; }; + }, [strategyId]); + + return ( +
+ +
+ ); +}; + +export default CandleChartWidgetHost; diff --git a/frontend/src/widgets/hosts/DepthChartWidgetHost.tsx b/frontend/src/widgets/hosts/DepthChartWidgetHost.tsx new file mode 100644 index 0000000..811c43b --- /dev/null +++ b/frontend/src/widgets/hosts/DepthChartWidgetHost.tsx @@ -0,0 +1,71 @@ +import React, { useMemo } from 'react'; +import { useUpbitOrderbook } from '../../hooks/useUpbitOrderbook'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; +import WidgetPanelHeader from '../panels/WidgetPanelHeader'; +import { resolveName } from '../widgetMarketUtils'; + +const DepthChartWidgetHost: React.FC = () => { + const { selectedMarket, tickers } = useWidgetDashboard(); + const { orderbook, spread } = useUpbitOrderbook(selectedMarket); + const ticker = tickers.get(selectedMarket); + + const { askBars, bidBars, maxSize } = useMemo(() => { + const asks = orderbook.asks.slice(0, 12).reverse(); + const bids = orderbook.bids.slice(0, 12); + const max = Math.max( + ...asks.map(a => a.size), + ...bids.map(b => b.size), + 1, + ); + return { askBars: asks, bidBars: bids, maxSize: max }; + }, [orderbook.asks, orderbook.bids]); + + return ( +
+ + 스프레드 {spread.spread?.toLocaleString('ko-KR') ?? '-'} + + ) : undefined} + /> +
{resolveName(selectedMarket, ticker)}
+
+
+ {askBars.map(u => ( +
+ {u.price.toLocaleString('ko-KR')} +
+
+
+ {u.size.toFixed(4)} +
+ ))} +
+
+ {ticker?.tradePrice?.toLocaleString('ko-KR') ?? '-'} +
+
+ {bidBars.map(u => ( +
+ {u.price.toLocaleString('ko-KR')} +
+
+
+ {u.size.toFixed(4)} +
+ ))} +
+
+
+ ); +}; + +export default DepthChartWidgetHost; diff --git a/frontend/src/widgets/hosts/MarketHeatmapWidgetHost.tsx b/frontend/src/widgets/hosts/MarketHeatmapWidgetHost.tsx new file mode 100644 index 0000000..76677a5 --- /dev/null +++ b/frontend/src/widgets/hosts/MarketHeatmapWidgetHost.tsx @@ -0,0 +1,43 @@ +import React, { useMemo } from 'react'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; +import WidgetPanelHeader from '../panels/WidgetPanelHeader'; +import { fmtPct, heatmapColor, resolveName, tickerListFromMap } from '../widgetMarketUtils'; + +const HEATMAP_SIZE = 48; + +const MarketHeatmapWidgetHost: React.FC = () => { + const { tickers, selectedMarket, setSelectedMarket } = useWidgetDashboard(); + + const cells = useMemo(() => { + return tickerListFromMap(tickers) + .filter(t => t.changeRate != null) + .sort((a, b) => (b.accTradePrice24 ?? 0) - (a.accTradePrice24 ?? 0)) + .slice(0, HEATMAP_SIZE); + }, [tickers]); + + return ( +
+ +
+ {cells.length === 0 &&

히트맵 데이터 수집 중…

} +
+ {cells.map(t => ( + + ))} +
+
+
+ ); +}; + +export default MarketHeatmapWidgetHost; diff --git a/frontend/src/widgets/hosts/MarketListWidgetHost.tsx b/frontend/src/widgets/hosts/MarketListWidgetHost.tsx new file mode 100644 index 0000000..262792f --- /dev/null +++ b/frontend/src/widgets/hosts/MarketListWidgetHost.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import MarketPanel from '../../components/MarketPanel'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; + +/** 종목 목록 — MarketPanel 재사용 */ +const MarketListWidgetHost: React.FC = () => { + const { + selectedMarket, setSelectedMarket, tickers, marketInfos, marketLoading, usdRate, + } = useWidgetDashboard(); + + return ( +
+ +
+ ); +}; + +export default MarketListWidgetHost; diff --git a/frontend/src/widgets/hosts/MarketMoversWidgetHost.tsx b/frontend/src/widgets/hosts/MarketMoversWidgetHost.tsx new file mode 100644 index 0000000..38e1260 --- /dev/null +++ b/frontend/src/widgets/hosts/MarketMoversWidgetHost.tsx @@ -0,0 +1,41 @@ +import React, { useMemo, useState } from 'react'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; +import WidgetPanelHeader from '../panels/WidgetPanelHeader'; +import WidgetSymbolList from '../panels/WidgetSymbolList'; +import { sortTickers, tickerListFromMap, type MoverSortKey } from '../widgetMarketUtils'; + +const TABS = [ + { id: 'gainers', label: '상승' }, + { id: 'losers', label: '하락' }, + { id: 'volume', label: '거래대금' }, +]; + +const MarketMoversWidgetHost: React.FC = () => { + const { tickers, selectedMarket, setSelectedMarket, marketLoading } = useWidgetDashboard(); + const [tab, setTab] = useState('gainers'); + + const items = useMemo(() => { + const list = tickerListFromMap(tickers); + return sortTickers(list, tab, 20); + }, [tickers, tab]); + + return ( +
+ setTab(id as MoverSortKey)} /> +
+ {marketLoading && items.length === 0 && ( +

시세 로딩 중…

+ )} + +
+
+ ); +}; + +export default MarketMoversWidgetHost; diff --git a/frontend/src/widgets/hosts/MarketSummaryWidgetHost.tsx b/frontend/src/widgets/hosts/MarketSummaryWidgetHost.tsx new file mode 100644 index 0000000..67b8981 --- /dev/null +++ b/frontend/src/widgets/hosts/MarketSummaryWidgetHost.tsx @@ -0,0 +1,67 @@ +import React, { useMemo } from 'react'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; +import WidgetPanelHeader from '../panels/WidgetPanelHeader'; +import { changeColorClass, fmtKrwShort, fmtPct, tickerListFromMap } from '../widgetMarketUtils'; + +const INDEX_SYMBOLS = ['KRW-BTC', 'KRW-ETH', 'KRW-XRP', 'KRW-SOL', 'KRW-USDT']; + +const MarketSummaryWidgetHost: React.FC = () => { + const { tickers, setSelectedMarket } = useWidgetDashboard(); + + const stats = useMemo(() => { + const list = tickerListFromMap(tickers); + const rising = list.filter(t => (t.changeRate ?? 0) > 0).length; + const falling = list.filter(t => (t.changeRate ?? 0) < 0).length; + const flat = list.length - rising - falling; + const totalVol = list.reduce((s, t) => s + (t.accTradePrice24 ?? 0), 0); + return { rising, falling, flat, total: list.length, totalVol }; + }, [tickers]); + + const indices = useMemo( + () => INDEX_SYMBOLS.map(sym => tickers.get(sym)).filter(Boolean), + [tickers], + ); + + return ( +
+ +
+
+
+ 상승 + {stats.rising} +
+
+ 하락 + {stats.falling} +
+
+ 보합 + {stats.flat} +
+
+ 24h 거래대금 + {fmtKrwShort(stats.totalVol)} +
+
+
    + {indices.map(t => { + if (!t) return null; + const cls = changeColorClass(t.changeRate); + return ( +
  • + +
  • + ); + })} +
+
+
+ ); +}; + +export default MarketSummaryWidgetHost; diff --git a/frontend/src/widgets/hosts/MiniChartWidgetHost.tsx b/frontend/src/widgets/hosts/MiniChartWidgetHost.tsx new file mode 100644 index 0000000..0206c38 --- /dev/null +++ b/frontend/src/widgets/hosts/MiniChartWidgetHost.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import PaperMiniChart from '../../components/paper/PaperMiniChart'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; +import WidgetPanelHeader from '../panels/WidgetPanelHeader'; +import { resolveName } from '../widgetMarketUtils'; + +const MiniChartWidgetHost: React.FC = () => { + const { selectedMarket, theme, tickers } = useWidgetDashboard(); + const ticker = tickers.get(selectedMarket); + + return ( +
+ +
{resolveName(selectedMarket, ticker)} · {selectedMarket}
+
+ +
+
+ ); +}; + +export default MiniChartWidgetHost; diff --git a/frontend/src/widgets/hosts/NotificationListWidgetHost.tsx b/frontend/src/widgets/hosts/NotificationListWidgetHost.tsx new file mode 100644 index 0000000..3a493b1 --- /dev/null +++ b/frontend/src/widgets/hosts/NotificationListWidgetHost.tsx @@ -0,0 +1,57 @@ +import React, { useState } from 'react'; +import { VirtualScroll } from '../../components/common/VirtualScroll'; +import TradeNotificationListRow from '../../components/tradeNotification/TradeNotificationListRow'; +import { useTradeNotification } from '../../contexts/TradeNotificationContext'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; + +/** 알림 목록 — TradeNotificationListRow 재사용 */ +const NotificationListWidgetHost: React.FC = () => { + const { allNotifications, markAsRead, deleteNotification, openDetail } = useTradeNotification(); + const { theme, tickers, strategies, setSelectedMarket } = useWidgetDashboard(); + const [selectedId, setSelectedId] = useState(null); + + if (allNotifications.length === 0) { + return

알림이 없습니다.

; + } + + return ( +
+ i.id} + innerClassName="vl-scroll-inner" + aria-label="알림 목록" + > + {item => ( + { + if (!item.isRead) markAsRead(item.id); + setSelectedMarket(item.market); + setSelectedId(item.id); + }} + onDelete={e => { + e.stopPropagation(); + void deleteNotification(item.id); + }} + onDetail={() => openDetail(item)} + onGoToChart={() => setSelectedMarket(item.market)} + itemTag="div" + prefetchedStrategies={strategies} + /> + )} + +
+ ); +}; + +export default NotificationListWidgetHost; diff --git a/frontend/src/widgets/hosts/OrderbookWidgetHost.tsx b/frontend/src/widgets/hosts/OrderbookWidgetHost.tsx new file mode 100644 index 0000000..ea32ad6 --- /dev/null +++ b/frontend/src/widgets/hosts/OrderbookWidgetHost.tsx @@ -0,0 +1,51 @@ +import React, { useMemo } from 'react'; +import TradeOrderbookPanelContent from '../../components/trade/TradeOrderbookPanelContent'; +import { useUpbitOrderbook } from '../../hooks/useUpbitOrderbook'; +import { useUpbitRecentTrades } from '../../hooks/useUpbitRecentTrades'; +import { useWidgetDashboard, useWidgetOrderbookFillHandler } from '../WidgetDashboardContext'; + +/** 호가 — TradeOrderbookPanelContent 재사용 */ +const OrderbookWidgetHost: React.FC = () => { + const { selectedMarket, tickers } = useWidgetDashboard(); + const onRowClick = useWidgetOrderbookFillHandler(); + const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(selectedMarket); + const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(selectedMarket); + const selectedTicker = tickers.get(selectedMarket); + const orderbookPrevClose = selectedTicker + ? (selectedTicker.tradePrice ?? 0) - (selectedTicker.changePrice ?? 0) + : 0; + const orderbookTickerInfo = selectedTicker ? { + tradePrice: selectedTicker.tradePrice, + changeRate: selectedTicker.changeRate, + changePrice: selectedTicker.changePrice, + accTradePrice24: selectedTicker.accTradePrice24, + accTradeVolume24: selectedTicker.accTradeVolume24, + highPrice: selectedTicker.highPrice, + lowPrice: selectedTicker.lowPrice, + openingPrice: selectedTicker.openingPrice, + } : undefined; + + return ( +
+ +
+ ); +}; + +export default OrderbookWidgetHost; diff --git a/frontend/src/widgets/hosts/PaperKpiWidgetHost.tsx b/frontend/src/widgets/hosts/PaperKpiWidgetHost.tsx new file mode 100644 index 0000000..66661fb --- /dev/null +++ b/frontend/src/widgets/hosts/PaperKpiWidgetHost.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import PaperInvestmentKpi from '../../components/paper/PaperInvestmentKpi'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; + +/** 투자 KPI — PaperInvestmentKpi 재사용 */ +const PaperKpiWidgetHost: React.FC = () => { + const { summary } = useWidgetDashboard(); + return ( +
+ +
+ ); +}; + +export default PaperKpiWidgetHost; diff --git a/frontend/src/widgets/hosts/PendingOrdersWidgetHost.tsx b/frontend/src/widgets/hosts/PendingOrdersWidgetHost.tsx new file mode 100644 index 0000000..3c0d664 --- /dev/null +++ b/frontend/src/widgets/hosts/PendingOrdersWidgetHost.tsx @@ -0,0 +1,66 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { cancelPaperOrder, loadPaperPendingOrders, type PaperOrderDto } from '../../utils/backendApi'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; +import WidgetPanelHeader from '../panels/WidgetPanelHeader'; +import { resolveName } from '../widgetMarketUtils'; + +const PendingOrdersWidgetHost: React.FC = () => { + const { tickers, refreshPaperData, setSelectedMarket } = useWidgetDashboard(); + const [orders, setOrders] = useState([]); + const [loading, setLoading] = useState(true); + + const load = useCallback(async () => { + setLoading(true); + try { + const list = await loadPaperPendingOrders(); + setOrders(list ?? []); + } catch { + setOrders([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + const timer = window.setInterval(() => { void load(); }, 15000); + return () => window.clearInterval(timer); + }, [load]); + + const handleCancel = async (id: number) => { + await cancelPaperOrder(id); + refreshPaperData(); + void load(); + }; + + return ( +
+ {orders.length}건} /> +
+ {loading && orders.length === 0 &&

주문 조회 중…

} + {!loading && orders.length === 0 &&

미체결 주문이 없습니다.

} +
    + {orders.map(o => { + const ticker = tickers.get(o.symbol); + const isBuy = o.side === 'BUY'; + return ( +
  • + + +
  • + ); + })} +
+
+
+ ); +}; + +export default PendingOrdersWidgetHost; diff --git a/frontend/src/widgets/hosts/PositionsWidgetHost.tsx b/frontend/src/widgets/hosts/PositionsWidgetHost.tsx new file mode 100644 index 0000000..b0af2bd --- /dev/null +++ b/frontend/src/widgets/hosts/PositionsWidgetHost.tsx @@ -0,0 +1,68 @@ +import React, { useMemo } from 'react'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; +import WidgetPanelHeader from '../panels/WidgetPanelHeader'; +import { changeColorClass, resolveName } from '../widgetMarketUtils'; + +const PositionsWidgetHost: React.FC = () => { + const { summary, tickers, setSelectedMarket } = useWidgetDashboard(); + + const rows = useMemo(() => { + const positions = summary?.positions ?? []; + return positions + .filter(p => p.quantity > 0) + .map(p => { + const ticker = tickers.get(p.symbol); + const mark = p.markPrice ?? ticker?.tradePrice ?? p.avgPrice; + const evalAmt = p.evalAmount ?? mark * p.quantity; + const pnl = p.profitLoss ?? evalAmt - p.avgPrice * p.quantity; + const pnlPct = p.profitLossPct ?? (p.avgPrice * p.quantity > 0 + ? (pnl / (p.avgPrice * p.quantity)) * 100 + : 0); + return { ...p, mark, evalAmt, pnl, pnlPct }; + }) + .sort((a, b) => b.evalAmt - a.evalAmt); + }, [summary, tickers]); + + return ( +
+ + 평가 {summary.stockEvalAmount.toLocaleString('ko-KR')}원 + + ) : undefined} + /> +
+ {rows.length === 0 && ( +

보유 중인 종목이 없습니다.

+ )} +
    + {rows.map(row => { + const cls = changeColorClass(row.pnlPct / 100); + const ticker = tickers.get(row.symbol); + return ( +
  • + +
  • + ); + })} +
+
+
+ ); +}; + +export default PositionsWidgetHost; diff --git a/frontend/src/widgets/hosts/QuoteWidgetHost.tsx b/frontend/src/widgets/hosts/QuoteWidgetHost.tsx new file mode 100644 index 0000000..0250f3f --- /dev/null +++ b/frontend/src/widgets/hosts/QuoteWidgetHost.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import VirtualTargetQuote from '../../components/virtual/VirtualTargetQuote'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; +import { changeColorClass, fmtKrwShort, fmtPct, resolveName } from '../widgetMarketUtils'; + +const QuoteWidgetHost: React.FC = () => { + const { selectedMarket, tickers, setSelectedMarket } = useWidgetDashboard(); + const ticker = tickers.get(selectedMarket); + const cls = changeColorClass(ticker?.changeRate); + + return ( +
+
+ {resolveName(selectedMarket, ticker)} + {selectedMarket} +
+
+ +
+
+
시가
+
{ticker?.openingPrice?.toLocaleString('ko-KR') ?? '-'}
+
+
+
고가
+
{ticker?.highPrice?.toLocaleString('ko-KR') ?? '-'}
+
+
+
저가
+
{ticker?.lowPrice?.toLocaleString('ko-KR') ?? '-'}
+
+
+
전일대비
+
+ {ticker?.changePrice != null + ? `${ticker.changePrice >= 0 ? '+' : ''}${ticker.changePrice.toLocaleString('ko-KR')} (${fmtPct(ticker.changeRate)})` + : '-'} +
+
+
+
24h 거래대금
+
{fmtKrwShort(ticker?.accTradePrice24)}
+
+
+
24h 거래량
+
{ticker?.accTradeVolume24?.toLocaleString('ko-KR', { maximumFractionDigits: 2 }) ?? '-'}
+
+
+ +
+
+ ); +}; + +export default QuoteWidgetHost; diff --git a/frontend/src/widgets/hosts/StrategyChartWidgetHost.tsx b/frontend/src/widgets/hosts/StrategyChartWidgetHost.tsx new file mode 100644 index 0000000..3147545 --- /dev/null +++ b/frontend/src/widgets/hosts/StrategyChartWidgetHost.tsx @@ -0,0 +1,30 @@ +import React, { useMemo } from 'react'; +import PaperAnalysisChart from '../../components/paper/PaperAnalysisChart'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; + +/** 전략 분석 차트 — PaperAnalysisChart 재사용 */ +const StrategyChartWidgetHost: React.FC<{ config?: Record }> = ({ config }) => { + const { theme, selectedMarket, strategies, selectedStrategyId } = useWidgetDashboard(); + const strategyId = useMemo(() => { + const fromConfig = config?.strategyId; + if (typeof fromConfig === 'number' && fromConfig > 0) return fromConfig; + if (selectedStrategyId != null && selectedStrategyId > 0) return selectedStrategyId; + return strategies[0]?.id ?? null; + }, [config?.strategyId, selectedStrategyId, strategies]); + + if (!strategyId) { + return

전략을 선택하세요.

; + } + + return ( +
+ +
+ ); +}; + +export default StrategyChartWidgetHost; diff --git a/frontend/src/widgets/hosts/StrategyListWidgetHost.tsx b/frontend/src/widgets/hosts/StrategyListWidgetHost.tsx new file mode 100644 index 0000000..b7baf50 --- /dev/null +++ b/frontend/src/widgets/hosts/StrategyListWidgetHost.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { VirtualScroll } from '../../components/common/VirtualScroll'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; + +/** 전략 목록 — API 로드된 strategies 재사용 */ +const StrategyListWidgetHost: React.FC = () => { + const { strategies, selectedStrategyId, setSelectedStrategyId } = useWidgetDashboard(); + + if (strategies.length === 0) { + return

등록된 전략이 없습니다.

; + } + + return ( +
+ String(s.id)} + innerClassName="vl-scroll-inner wd-strategy-list" + aria-label="전략 목록" + > + {s => ( + + )} + +
+ ); +}; + +export default StrategyListWidgetHost; diff --git a/frontend/src/widgets/hosts/TickerTapeWidgetHost.tsx b/frontend/src/widgets/hosts/TickerTapeWidgetHost.tsx new file mode 100644 index 0000000..1e070e7 --- /dev/null +++ b/frontend/src/widgets/hosts/TickerTapeWidgetHost.tsx @@ -0,0 +1,73 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; +import { changeColorClass, fmtPct, resolveName, tickerListFromMap } from '../widgetMarketUtils'; + +const TAPE_COUNT = 40; + +const TickerTapeWidgetHost: React.FC = () => { + const { tickers, setSelectedMarket } = useWidgetDashboard(); + const trackRef = useRef(null); + const [paused, setPaused] = useState(false); + + const tapeItems = useMemo(() => { + const list = tickerListFromMap(tickers) + .sort((a, b) => (b.accTradePrice24 ?? 0) - (a.accTradePrice24 ?? 0)) + .slice(0, TAPE_COUNT); + return [...list, ...list]; + }, [tickers]); + + useEffect(() => { + const el = trackRef.current; + if (!el || tapeItems.length === 0) return; + let frame = 0; + let offset = 0; + const step = () => { + if (!paused) { + offset -= 0.6; + if (Math.abs(offset) >= el.scrollWidth / 2) offset = 0; + el.style.transform = `translateX(${offset}px)`; + } + frame = requestAnimationFrame(step); + }; + frame = requestAnimationFrame(step); + return () => cancelAnimationFrame(frame); + }, [tapeItems, paused]); + + if (tapeItems.length === 0) { + return ( +
+

티커 데이터 수집 중…

+
+ ); + } + + return ( +
setPaused(true)} + onMouseLeave={() => setPaused(false)} + > +
+
+ {tapeItems.map((t, i) => { + const cls = changeColorClass(t.changeRate); + return ( + + ); + })} +
+
+
+ ); +}; + +export default TickerTapeWidgetHost; diff --git a/frontend/src/widgets/hosts/TimeSalesWidgetHost.tsx b/frontend/src/widgets/hosts/TimeSalesWidgetHost.tsx new file mode 100644 index 0000000..493f4ca --- /dev/null +++ b/frontend/src/widgets/hosts/TimeSalesWidgetHost.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { useUpbitRecentTrades } from '../../hooks/useUpbitRecentTrades'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; +import WidgetPanelHeader from '../panels/WidgetPanelHeader'; +import { resolveName } from '../widgetMarketUtils'; + +function fmtTime(ts: number): string { + const d = new Date(ts); + return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +const TimeSalesWidgetHost: React.FC = () => { + const { selectedMarket, tickers } = useWidgetDashboard(); + const { trades, strength } = useUpbitRecentTrades(selectedMarket, true, 50); + const ticker = tickers.get(selectedMarket); + + return ( +
+ 체결강도 {strength.toFixed(0)}% + ) : undefined} + /> +
{resolveName(selectedMarket, ticker)} · {selectedMarket}
+
+ + + + + + + + + + + {trades.length === 0 && ( + + )} + {trades.map((t, i) => ( + + + + + + + ))} + +
시간체결가체결량구분
체결 데이터 수집 중…
{fmtTime(t.time)}{t.price.toLocaleString('ko-KR')}{t.volume >= 1 ? t.volume.toFixed(4) : t.volume.toFixed(6)}{t.side === 'bid' ? '매수' : '매도'}
+
+
+ ); +}; + +export default TimeSalesWidgetHost; diff --git a/frontend/src/widgets/hosts/TradeHistoryWidgetHost.tsx b/frontend/src/widgets/hosts/TradeHistoryWidgetHost.tsx new file mode 100644 index 0000000..62e0e3c --- /dev/null +++ b/frontend/src/widgets/hosts/TradeHistoryWidgetHost.tsx @@ -0,0 +1,26 @@ +import React, { useMemo } from 'react'; +import PaperTradeHistoryList from '../../components/paper/PaperTradeHistoryList'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; + +/** 거래 내역 — PaperTradeHistoryList 재사용 */ +const TradeHistoryWidgetHost: React.FC = () => { + const { trades, strategies, tickers, setSelectedMarket } = useWidgetDashboard(); + const strategyNames = useMemo( + () => Object.fromEntries(strategies.map(s => [s.id, s.name])), + [strategies], + ); + + return ( +
+ +
+ ); +}; + +export default TradeHistoryWidgetHost; diff --git a/frontend/src/widgets/hosts/WatchlistWidgetHost.tsx b/frontend/src/widgets/hosts/WatchlistWidgetHost.tsx new file mode 100644 index 0000000..55dd37a --- /dev/null +++ b/frontend/src/widgets/hosts/WatchlistWidgetHost.tsx @@ -0,0 +1,87 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { FAVORITES_CHANGED_EVENT, getFavorites, toggleFavorite } from '../../utils/marketStorage'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; +import WidgetPanelHeader from '../panels/WidgetPanelHeader'; + +const WatchlistWidgetHost: React.FC = () => { + const { tickers, selectedMarket, setSelectedMarket } = useWidgetDashboard(); + const [favorites, setFavorites] = useState(() => getFavorites()); + + useEffect(() => { + const onChange = (e: Event) => { + setFavorites((e as CustomEvent).detail ?? getFavorites()); + }; + window.addEventListener(FAVORITES_CHANGED_EVENT, onChange); + return () => window.removeEventListener(FAVORITES_CHANGED_EVENT, onChange); + }, []); + + const handleToggle = useCallback(async (market: string, e: React.MouseEvent) => { + e.stopPropagation(); + const row = tickers.get(market); + try { + await toggleFavorite(market, { koreanName: row?.koreanName }); + setFavorites(getFavorites()); + } catch { + /* ignore */ + } + }, [tickers]); + + return ( +
+ {favorites.length}종목} /> +
+ {favorites.length === 0 && ( +

관심종목이 없습니다. 종목 목록에서 ★를 눌러 추가하세요.

+ )} + {favorites.length > 0 && ( +
    + {favorites.map(market => { + const t = tickers.get(market); + if (!t) { + return ( +
  • + +
  • + ); + } + const rate = t.changeRate; + const cls = rate != null && rate > 0 ? 'wd-up' : rate != null && rate < 0 ? 'wd-down' : 'wd-muted'; + return ( +
  • +
    + + +
    +
  • + ); + })} +
+ )} +
+
+ ); +}; + +export default WatchlistWidgetHost; diff --git a/frontend/src/widgets/panels/WidgetPanelHeader.tsx b/frontend/src/widgets/panels/WidgetPanelHeader.tsx new file mode 100644 index 0000000..1bec5ec --- /dev/null +++ b/frontend/src/widgets/panels/WidgetPanelHeader.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +interface Tab { + id: string; + label: string; +} + +interface Props { + title?: string; + tabs?: Tab[]; + activeTab?: string; + onTabChange?: (id: string) => void; + right?: React.ReactNode; +} + +const WidgetPanelHeader: React.FC = ({ + title, + tabs, + activeTab, + onTabChange, + right, +}) => ( +
+ {title && {title}} + {tabs && tabs.length > 0 && ( +
+ {tabs.map(tab => ( + + ))} +
+ )} + {right &&
{right}
} +
+); + +export default WidgetPanelHeader; diff --git a/frontend/src/widgets/panels/WidgetSymbolList.tsx b/frontend/src/widgets/panels/WidgetSymbolList.tsx new file mode 100644 index 0000000..fd21fae --- /dev/null +++ b/frontend/src/widgets/panels/WidgetSymbolList.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import type { TickerData } from '../../hooks/useMarketTicker'; +import { changeColorClass, fmtKrwShort, fmtPct, resolveName } from '../widgetMarketUtils'; + +interface Props { + items: TickerData[]; + activeMarket?: string; + onSelect?: (market: string) => void; + showVolume?: boolean; + rank?: boolean; +} + +const WidgetSymbolList: React.FC = ({ + items, + activeMarket, + onSelect, + showVolume = false, + rank = false, +}) => { + if (items.length === 0) { + return

표시할 종목이 없습니다.

; + } + + return ( +
    + {items.map((t, i) => { + const rate = t.changeRate; + const cls = changeColorClass(rate); + return ( +
  • + +
  • + ); + })} +
+ ); +}; + +export default WidgetSymbolList; diff --git a/frontend/src/widgets/widgetMarketUtils.ts b/frontend/src/widgets/widgetMarketUtils.ts new file mode 100644 index 0000000..050e3d4 --- /dev/null +++ b/frontend/src/widgets/widgetMarketUtils.ts @@ -0,0 +1,55 @@ +import type { TickerData } from '../hooks/useMarketTicker'; +import { getKoreanName } from '../utils/marketNameCache'; +import { safePercentFromRate } from '../utils/safeFormat'; + +export type MoverSortKey = 'gainers' | 'losers' | 'volume'; + +export function tickerListFromMap(tickers: Map): TickerData[] { + return [...tickers.values()].filter(t => t.market.startsWith('KRW-') && t.tradePrice != null); +} + +export function sortTickers(items: TickerData[], key: MoverSortKey, limit = 15): TickerData[] { + const copy = [...items]; + if (key === 'gainers') { + return copy + .filter(t => (t.changeRate ?? 0) > 0) + .sort((a, b) => (b.changeRate ?? 0) - (a.changeRate ?? 0)) + .slice(0, limit); + } + if (key === 'losers') { + return copy + .filter(t => (t.changeRate ?? 0) < 0) + .sort((a, b) => (a.changeRate ?? 0) - (b.changeRate ?? 0)) + .slice(0, limit); + } + return copy + .sort((a, b) => (b.accTradePrice24 ?? 0) - (a.accTradePrice24 ?? 0)) + .slice(0, limit); +} + +export function changeColorClass(rate: number | null | undefined): string { + if (rate == null || rate === 0) return 'wd-muted'; + return rate > 0 ? 'wd-up' : 'wd-down'; +} + +export function fmtPct(rate: number | null | undefined): string { + return safePercentFromRate(rate, 2).replace('—', '-'); +} + +export function fmtKrwShort(n: number | null | undefined): string { + if (n == null || !Number.isFinite(n)) return '-'; + if (n >= 1_0000_0000) return `${(n / 1_0000_0000).toFixed(1)}억`; + if (n >= 1_0000) return `${Math.round(n / 1_0000).toLocaleString('ko-KR')}만`; + return n.toLocaleString('ko-KR', { maximumFractionDigits: 0 }); +} + +export function resolveName(market: string, ticker?: TickerData): string { + return ticker?.koreanName || getKoreanName(market) || market.replace('KRW-', ''); +} + +export function heatmapColor(rate: number | null | undefined): string { + if (rate == null || rate === 0) return 'rgba(120, 130, 150, 0.35)'; + const intensity = Math.min(1, Math.abs(rate) * 8); + if (rate > 0) return `rgba(231, 76, 60, ${0.25 + intensity * 0.55})`; + return `rgba(52, 152, 219, ${0.25 + intensity * 0.55})`; +} diff --git a/frontend/src/widgets/widgetRegistry.ts b/frontend/src/widgets/widgetRegistry.ts new file mode 100644 index 0000000..49da6fc --- /dev/null +++ b/frontend/src/widgets/widgetRegistry.ts @@ -0,0 +1,267 @@ +import type React from 'react'; +import type { WidgetCategory } from '../types/widgetDashboard'; +import CandleChartWidgetHost from './hosts/CandleChartWidgetHost'; +import StrategyChartWidgetHost from './hosts/StrategyChartWidgetHost'; +import MiniChartWidgetHost from './hosts/MiniChartWidgetHost'; +import MarketListWidgetHost from './hosts/MarketListWidgetHost'; +import MarketMoversWidgetHost from './hosts/MarketMoversWidgetHost'; +import TickerTapeWidgetHost from './hosts/TickerTapeWidgetHost'; +import QuoteWidgetHost from './hosts/QuoteWidgetHost'; +import WatchlistWidgetHost from './hosts/WatchlistWidgetHost'; +import MarketHeatmapWidgetHost from './hosts/MarketHeatmapWidgetHost'; +import MarketSummaryWidgetHost from './hosts/MarketSummaryWidgetHost'; +import BuySellWidgetHost from './hosts/BuySellWidgetHost'; +import OrderbookWidgetHost from './hosts/OrderbookWidgetHost'; +import DepthChartWidgetHost from './hosts/DepthChartWidgetHost'; +import TimeSalesWidgetHost from './hosts/TimeSalesWidgetHost'; +import TradeHistoryWidgetHost from './hosts/TradeHistoryWidgetHost'; +import PendingOrdersWidgetHost from './hosts/PendingOrdersWidgetHost'; +import PositionsWidgetHost from './hosts/PositionsWidgetHost'; +import AccountBalanceWidgetHost from './hosts/AccountBalanceWidgetHost'; +import NotificationListWidgetHost from './hosts/NotificationListWidgetHost'; +import StrategyListWidgetHost from './hosts/StrategyListWidgetHost'; +import BacktestListWidgetHost from './hosts/BacktestListWidgetHost'; +import PaperKpiWidgetHost from './hosts/PaperKpiWidgetHost'; + +export type WidgetGroupId = + | 'chart' + | 'market' + | 'trade' + | 'portfolio' + | 'strategy' + | 'alert'; + +export interface WidgetDefinition { + type: string; + label: string; + description: string; + category: WidgetCategory; + group: WidgetGroupId; + defaultConfig?: Record; + Host: React.FC<{ config?: Record }>; +} + +export const WIDGET_GROUP_LABELS: Record = { + chart: '차트', + market: '시세·종목', + trade: '매매·호가', + portfolio: '계좌·포트폴리오', + strategy: '전략·분석', + alert: '알림', +}; + +export const WIDGET_REGISTRY: WidgetDefinition[] = [ + { + type: 'chart.candle', + label: '캔들 차트', + description: '종목 검색 + 좌·우 툴바 실시간 캔들', + category: 'center', + group: 'chart', + Host: CandleChartWidgetHost, + }, + { + type: 'chart.strategy', + label: '전략 분석 차트', + description: '선택 전략 백테스트 시그널 차트', + category: 'center', + group: 'chart', + Host: StrategyChartWidgetHost, + }, + { + type: 'chart.mini', + label: '미니 차트', + description: '선택 종목 소형 캔들 차트', + category: 'center', + group: 'chart', + Host: MiniChartWidgetHost, + }, + { + type: 'market.list', + label: '종목 목록', + description: 'KRW·BTC·보유·관심 종목', + category: 'side', + group: 'market', + Host: MarketListWidgetHost, + }, + { + type: 'market.watchlist', + label: '관심종목', + description: '즐겨찾기 종목 컴팩트 목록', + category: 'side', + group: 'market', + Host: WatchlistWidgetHost, + }, + { + type: 'market.movers', + label: '급등·급락', + description: '상승·하락·거래대금 TOP 종목', + category: 'side', + group: 'market', + Host: MarketMoversWidgetHost, + }, + { + type: 'market.tickerTape', + label: '티커 테이프', + description: '주요 종목 시세 흐름 스크롤', + category: 'side', + group: 'market', + Host: TickerTapeWidgetHost, + }, + { + type: 'market.quote', + label: '종목 시세', + description: '선택 종목 상세 시세·OHLC', + category: 'side', + group: 'market', + Host: QuoteWidgetHost, + }, + { + type: 'market.heatmap', + label: '히트맵', + description: '등락률 색상 그리드', + category: 'side', + group: 'market', + Host: MarketHeatmapWidgetHost, + }, + { + type: 'market.summary', + label: '시장 요약', + description: '상승/하락 종목 수·주요 지수', + category: 'side', + group: 'market', + Host: MarketSummaryWidgetHost, + }, + { + type: 'trade.buySell', + label: '매수·매도', + description: '모의 매매 주문 패널', + category: 'side', + group: 'trade', + Host: BuySellWidgetHost, + }, + { + type: 'trade.orderbook', + label: '호가', + description: '실시간 호가·체결', + category: 'side', + group: 'trade', + Host: OrderbookWidgetHost, + }, + { + type: 'trade.depth', + label: '호가 깊이', + description: '매수·매도 호가 깊이 차트', + category: 'side', + group: 'trade', + Host: DepthChartWidgetHost, + }, + { + type: 'trade.timeSales', + label: '체결', + description: '실시간 체결(Time & Sales)', + category: 'side', + group: 'trade', + Host: TimeSalesWidgetHost, + }, + { + type: 'trade.history', + label: '거래 내역', + description: '모의·가상 체결 이력', + category: 'side', + group: 'trade', + Host: TradeHistoryWidgetHost, + }, + { + type: 'trade.pendingOrders', + label: '미체결 주문', + description: '대기 중인 모의 주문', + category: 'side', + group: 'trade', + Host: PendingOrdersWidgetHost, + }, + { + type: 'paper.kpi', + label: '투자 KPI', + description: '총자산·수익률·예수금', + category: 'side', + group: 'portfolio', + Host: PaperKpiWidgetHost, + }, + { + type: 'paper.balance', + label: '계좌 잔고', + description: '예수금·평가·손익 상세', + category: 'side', + group: 'portfolio', + Host: AccountBalanceWidgetHost, + }, + { + type: 'paper.positions', + label: '보유 종목', + description: '포지션·평가손익', + category: 'side', + group: 'portfolio', + Host: PositionsWidgetHost, + }, + { + type: 'notification.list', + label: '알림 목록', + description: '매매 시그널 알림', + category: 'side', + group: 'alert', + Host: NotificationListWidgetHost, + }, + { + type: 'strategy.list', + label: '전략 목록', + description: '등록된 투자 전략', + category: 'side', + group: 'strategy', + Host: StrategyListWidgetHost, + }, + { + type: 'backtest.list', + label: '백테스트 목록', + description: '백테스팅·실시간 매매 실행', + category: 'side', + group: 'strategy', + Host: BacktestListWidgetHost, + }, +]; + +const byType = new Map(WIDGET_REGISTRY.map(w => [w.type, w])); + +export function getWidgetDefinition(type: string | null | undefined): WidgetDefinition | undefined { + if (!type) return undefined; + return byType.get(type); +} + +export function widgetsForCategory(category: WidgetCategory): WidgetDefinition[] { + return WIDGET_REGISTRY.filter(w => w.category === category); +} + +export function widgetsByGroup(category: WidgetCategory): { group: WidgetGroupId; label: string; items: WidgetDefinition[] }[] { + const items = widgetsForCategory(category); + const order: WidgetGroupId[] = category === 'center' + ? ['chart'] + : ['market', 'trade', 'portfolio', 'strategy', 'alert']; + return order + .map(group => ({ + group, + label: WIDGET_GROUP_LABELS[group], + items: items.filter(w => w.group === group), + })) + .filter(g => g.items.length > 0); +} + +/** 플로팅 위젯 — 메인·패널 위젯 전체 */ +export function widgetsByGroupAll(): { group: WidgetGroupId; label: string; items: WidgetDefinition[] }[] { + return [ + ...widgetsByGroup('center'), + ...widgetsByGroup('side'), + ]; +} + +export function widgetLabel(type: string | null | undefined): string { + return getWidgetDefinition(type)?.label ?? '위젯 선택'; +}