위젯 기능 추가
This commit is contained in:
@@ -7,7 +7,7 @@ public final class MenuIds {
|
|||||||
private MenuIds() {}
|
private MenuIds() {}
|
||||||
|
|
||||||
public static final List<String> ALL = List.of(
|
public static final List<String> 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_general", "settings_chart", "settings_indicators", "settings_backtest",
|
||||||
"settings_strategy", "settings_trading", "settings_paper", "settings_virtual", "settings_live",
|
"settings_strategy", "settings_trading", "settings_paper", "settings_virtual", "settings_live",
|
||||||
"settings_trend-search",
|
"settings_trend-search",
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from './utils/tradingAccess';
|
} from './utils/tradingAccess';
|
||||||
import { NotifyTopMenuBar } from './components/NotifyUiBindings';
|
import { NotifyTopMenuBar } from './components/NotifyUiBindings';
|
||||||
import { AppNotificationLayer } from './components/AppNotificationLayer';
|
import { AppNotificationLayer } from './components/AppNotificationLayer';
|
||||||
|
import FloatingWidgetLayer from './components/floatingWidgets/FloatingWidgetLayer';
|
||||||
import type { Theme } from './types';
|
import type { Theme } from './types';
|
||||||
import type { ChartRealtimeSource } from './hooks/useChartRealtimeData';
|
import type { ChartRealtimeSource } from './hooks/useChartRealtimeData';
|
||||||
import { useIsMobile } from './hooks/useMediaQuery';
|
import { useIsMobile } from './hooks/useMediaQuery';
|
||||||
@@ -74,6 +75,7 @@ const VirtualTradingPage = lazy(() => import('./components/VirtualTradingPage'))
|
|||||||
const StrategyEvaluationPage = lazy(() => import('./components/StrategyEvaluationPage'));
|
const StrategyEvaluationPage = lazy(() => import('./components/StrategyEvaluationPage'));
|
||||||
const TrendSearchPage = lazy(() => import('./components/TrendSearchPage'));
|
const TrendSearchPage = lazy(() => import('./components/TrendSearchPage'));
|
||||||
const VerificationBoardPage = lazy(() => import('./components/VerificationBoardPage'));
|
const VerificationBoardPage = lazy(() => import('./components/VerificationBoardPage'));
|
||||||
|
const WidgetDashboardPage = lazy(() => import('./components/WidgetDashboardPage'));
|
||||||
|
|
||||||
function PageLoadFallback() {
|
function PageLoadFallback() {
|
||||||
return <div className="page-load-fallback" role="status">화면 로딩 중…</div>;
|
return <div className="page-load-fallback" role="status">화면 로딩 중…</div>;
|
||||||
@@ -165,6 +167,10 @@ function AppMainContent({
|
|||||||
const paperTradingEnabled = appDefaults.paperTradingEnabled ?? true;
|
const paperTradingEnabled = appDefaults.paperTradingEnabled ?? true;
|
||||||
const paperAutoTradeEnabled = appDefaults.paperAutoTradeEnabled ?? false;
|
const paperAutoTradeEnabled = appDefaults.paperAutoTradeEnabled ?? false;
|
||||||
|
|
||||||
|
const [floatingLayoutPickerOpen, setFloatingLayoutPickerOpen] = useState(false);
|
||||||
|
const [floatingWidgetCount, setFloatingWidgetCount] = useState(0);
|
||||||
|
const showFloatingWidgets = canMenu('widget-dashboard');
|
||||||
|
|
||||||
const handleFullscreen = () => {
|
const handleFullscreen = () => {
|
||||||
if (!document.fullscreenElement) document.documentElement.requestFullscreen().catch(() => {});
|
if (!document.fullscreenElement) document.documentElement.requestFullscreen().catch(() => {});
|
||||||
else document.exitFullscreen().catch(() => {});
|
else document.exitFullscreen().catch(() => {});
|
||||||
@@ -194,6 +200,8 @@ function AppMainContent({
|
|||||||
onTradeAlertPopupLayout={v => saveAppDef({ tradeAlertPopupLayout: v })}
|
onTradeAlertPopupLayout={v => saveAppDef({ tradeAlertPopupLayout: v })}
|
||||||
onTradeAlertPopupGridCols={v => saveAppDef({ tradeAlertPopupGridCols: v })}
|
onTradeAlertPopupGridCols={v => saveAppDef({ tradeAlertPopupGridCols: v })}
|
||||||
onFullscreen={handleFullscreen}
|
onFullscreen={handleFullscreen}
|
||||||
|
onOpenFloatingWidgets={showFloatingWidgets ? () => setFloatingLayoutPickerOpen(true) : undefined}
|
||||||
|
floatingWidgetCount={floatingWidgetCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LoginModal
|
<LoginModal
|
||||||
@@ -212,6 +220,18 @@ function AppMainContent({
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{menuPage === 'widget-dashboard' && (
|
||||||
|
<Suspense fallback={<PageLoadFallback />}>
|
||||||
|
<WidgetDashboardPage
|
||||||
|
theme={theme}
|
||||||
|
paperTradingEnabled={paperTradingEnabled}
|
||||||
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||||
|
onPaperOrderFilled={handlePaperOrderFilled}
|
||||||
|
chartRealtimeSource={(appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP') as ChartRealtimeSource}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
|
||||||
{menuPage === 'strategy' && (
|
{menuPage === 'strategy' && (
|
||||||
formalLogin
|
formalLogin
|
||||||
? (
|
? (
|
||||||
@@ -441,6 +461,20 @@ function AppMainContent({
|
|||||||
|
|
||||||
{chartSlot}
|
{chartSlot}
|
||||||
|
|
||||||
|
{showFloatingWidgets && (
|
||||||
|
<FloatingWidgetLayer
|
||||||
|
theme={theme}
|
||||||
|
defaultMarket={symbol}
|
||||||
|
layoutPickerOpen={floatingLayoutPickerOpen}
|
||||||
|
onLayoutPickerOpenChange={setFloatingLayoutPickerOpen}
|
||||||
|
paperTradingEnabled={paperTradingEnabled}
|
||||||
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||||
|
onPaperOrderFilled={handlePaperOrderFilled}
|
||||||
|
chartRealtimeSource={(appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP') as ChartRealtimeSource}
|
||||||
|
onInstancesChange={setFloatingWidgetCount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<AppNotificationLayer
|
<AppNotificationLayer
|
||||||
menuPage={menuPage}
|
menuPage={menuPage}
|
||||||
onPage={guardedSetMenuPage}
|
onPage={guardedSetMenuPage}
|
||||||
|
|||||||
@@ -0,0 +1,542 @@
|
|||||||
|
/**
|
||||||
|
* 실시간 차트 툴바와 동일한 보조지표 추가·제거 패널 (ind-panel)
|
||||||
|
*/
|
||||||
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||||
|
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, getIndicatorListLabels } from '../utils/indicatorSettingsList';
|
||||||
|
import {
|
||||||
|
loadCustomTabs,
|
||||||
|
createCustomTab,
|
||||||
|
updateCustomTab,
|
||||||
|
deleteCustomTab,
|
||||||
|
type IndicatorCustomTab,
|
||||||
|
} from '../utils/indicatorCustomTabsStorage';
|
||||||
|
import IndicatorCustomTabEditor from './IndicatorCustomTabEditor';
|
||||||
|
import type { IndicatorConfig } from '../types';
|
||||||
|
|
||||||
|
const IcSearch = () => (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<circle cx="6" cy="6" r="4" /><line x1="9.5" y1="9.5" x2="13" y2="13" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IcPlus = () => (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||||
|
<line x1="7" y1="2" x2="7" y2="12" /><line x1="2" y1="7" x2="12" y2="7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IcGear = () => (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4">
|
||||||
|
<circle cx="7" cy="7" r="2.3" />
|
||||||
|
<path d="M7 1 L7.7 2.8 L9.8 2 L9.8 4.2 L11.8 5 L11 7 L11.8 9 L9.8 9.8 L9.8 12 L7.7 11.2 L7 13 L6.3 11.2 L4.2 12 L4.2 9.8 L2.2 9 L3 7 L2.2 5 L4.2 4.2 L4.2 2 L6.3 2.8 Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
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<IndicatorDropdownPanelProps> = ({
|
||||||
|
activeIndicators,
|
||||||
|
onAdd,
|
||||||
|
onAddMany,
|
||||||
|
onIndicatorOrderChange,
|
||||||
|
onRemove,
|
||||||
|
onOpenSettings,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [category, setCategory] = useState<IndicatorTabKey>('All');
|
||||||
|
const [customTabs, setCustomTabs] = useState<IndicatorCustomTab[]>(() => loadCustomTabs());
|
||||||
|
const [mainOrderTick, setMainOrderTick] = useState(0);
|
||||||
|
const [customTabsRevision, setCustomTabsRevision] = useState(0);
|
||||||
|
const [draggingType, setDraggingType] = useState<string | null>(null);
|
||||||
|
const [dragOverType, setDragOverType] = useState<string | null>(null);
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
|
const [editorMode, setEditorMode] = useState<'create' | 'edit'>('create');
|
||||||
|
const searchRef = useRef<HTMLInputElement>(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 (
|
||||||
|
<div className="ind-overlay" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="ind-panel"
|
||||||
|
style={{
|
||||||
|
...panelStyle,
|
||||||
|
zIndex: 2001,
|
||||||
|
cursor: dragging ? 'grabbing' : undefined,
|
||||||
|
}}
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="gc-popup-header"
|
||||||
|
onPointerDown={onHeaderPointerDown}
|
||||||
|
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||||
|
>
|
||||||
|
<span className="gc-popup-title">지표 추가</span>
|
||||||
|
<div className="ind-panel-header-actions">
|
||||||
|
<button type="button" className="gc-popup-close" onClick={onClose} aria-label="닫기">✕</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ind-panel-header-btn"
|
||||||
|
onPointerDown={stopHeaderBtn}
|
||||||
|
onClick={openCreateEditor}
|
||||||
|
>
|
||||||
|
탭 추가
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ind-panel-header-btn"
|
||||||
|
disabled={!selectedCustomTab}
|
||||||
|
onPointerDown={stopHeaderBtn}
|
||||||
|
onClick={openEditEditor}
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ind-panel-header-btn danger"
|
||||||
|
disabled={!selectedCustomTab}
|
||||||
|
onPointerDown={stopHeaderBtn}
|
||||||
|
onClick={handleDeleteTab}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ind-panel-search-row">
|
||||||
|
<div className="ind-panel-search-wrap">
|
||||||
|
<IcSearch />
|
||||||
|
<input
|
||||||
|
ref={searchRef}
|
||||||
|
className="ind-panel-search"
|
||||||
|
placeholder={`지표 검색... (전체 ${INDICATOR_REGISTRY.length}개)`}
|
||||||
|
value={search}
|
||||||
|
onChange={e => { setSearch(e.target.value); setCategory('All'); }}
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button type="button" className="ind-panel-clear" onClick={() => setSearch('')}>✕</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ind-panel-cats-row">
|
||||||
|
<div className="ind-panel-cats" role="tablist" aria-label="지표 탭">
|
||||||
|
{BUILTIN_TABS.map(c => {
|
||||||
|
const cnt = c.key === 'All'
|
||||||
|
? INDICATOR_REGISTRY.length
|
||||||
|
: MAIN_INDICATOR_TYPES.length;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={category === c.key}
|
||||||
|
className={`ind-panel-cat${category === c.key ? ' active' : ''}`}
|
||||||
|
onClick={() => { setCategory(c.key); setSearch(''); }}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
<span className="ind-panel-cat-cnt"> {cnt}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{customTabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={category === customTabKey(tab.id)}
|
||||||
|
className={`ind-panel-cat${category === customTabKey(tab.id) ? ' active' : ''}`}
|
||||||
|
onClick={() => { setCategory(customTabKey(tab.id)); setSearch(''); }}
|
||||||
|
>
|
||||||
|
{tab.name}
|
||||||
|
<span className="ind-panel-cat-cnt"> {tab.indicatorTypes.length}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ind-panel-add-all"
|
||||||
|
disabled={addAllDisabled}
|
||||||
|
title={addAllDisabled ? '전체 탭에서는 사용할 수 없습니다' : '기존 보조지표를 제거하고 현재 탭 지표로 교체'}
|
||||||
|
aria-label="현재 탭 지표로 보조지표 교체"
|
||||||
|
onClick={handleAddAllInTab}
|
||||||
|
>
|
||||||
|
<IcPlus />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ind-panel-list">
|
||||||
|
{activeIndicators.length > 0 && !search && category === 'All' && (
|
||||||
|
<div className="ind-panel-section">
|
||||||
|
<div className="ind-panel-section-title">활성 지표 ({activeIndicators.length})</div>
|
||||||
|
{activeIndicators.map(a => {
|
||||||
|
const lbl = getIndicatorListLabels(a.type);
|
||||||
|
return (
|
||||||
|
<div key={a.id} className="ind-panel-item active">
|
||||||
|
<div className="ind-panel-item-info">
|
||||||
|
<span className="ind-panel-name">{lbl.ko}</span>
|
||||||
|
<span className="ind-panel-desc">{lbl.en}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ind-panel-item-right">
|
||||||
|
<button type="button" className="ind-panel-rm" onClick={() => onRemove(a.type)}>제거</button>
|
||||||
|
{onOpenSettings && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ind-panel-settings"
|
||||||
|
title="설정"
|
||||||
|
aria-label={`${lbl.ko} 설정`}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenSettings(a.type);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IcGear />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="ind-panel-divider" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="ind-panel-empty">
|
||||||
|
{search
|
||||||
|
? `"${search}" 검색 결과 없음`
|
||||||
|
: isCustomTabKey(category)
|
||||||
|
? '이 탭에 표시할 지표가 없습니다. 수정 버튼으로 지표를 추가해 주세요.'
|
||||||
|
: '표시할 지표가 없습니다'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
key={def.type}
|
||||||
|
className={`ind-panel-item${active ? ' active' : ''}${isDragOver ? ' ind-panel-item--drag-over' : ''}`}
|
||||||
|
onDragOver={canReorderList ? e => {
|
||||||
|
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 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ind-settings-drag-handle ind-panel-drag-handle"
|
||||||
|
draggable
|
||||||
|
title="드래그하여 순서 변경"
|
||||||
|
aria-label="순서 변경"
|
||||||
|
onDragStart={e => {
|
||||||
|
setDraggingType(def.type);
|
||||||
|
e.dataTransfer.setData('text/indicator-type', def.type);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
}}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
⠿
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="ind-panel-item-info">
|
||||||
|
<span className="ind-panel-name">{mainLbl?.ko ?? def.description}</span>
|
||||||
|
<span className="ind-panel-desc">{mainLbl?.en ?? def.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ind-panel-item-right">
|
||||||
|
{def.overlay && <span className="ind-panel-badge">오버레이</span>}
|
||||||
|
{active ? (
|
||||||
|
<button type="button" className="ind-panel-rm" onClick={() => onRemove(def.type)}>제거</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ind-panel-add"
|
||||||
|
title="차트에 추가"
|
||||||
|
onClick={() => {
|
||||||
|
onAdd(def.type);
|
||||||
|
syncChartToTabOrder();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IcPlus />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onOpenSettings && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ind-panel-settings"
|
||||||
|
title="설정"
|
||||||
|
aria-label={`${mainLbl?.ko ?? def.koreanName} 설정`}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenSettings(def.type);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IcGear />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editorOpen && (
|
||||||
|
<IndicatorCustomTabEditor
|
||||||
|
mode={editorMode}
|
||||||
|
initialName={editorMode === 'edit' && selectedCustomTab ? selectedCustomTab.name : ''}
|
||||||
|
initialTypes={editorMode === 'edit' && selectedCustomTab ? selectedCustomTab.indicatorTypes : []}
|
||||||
|
onSave={handleEditorSave}
|
||||||
|
onClose={() => setEditorOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IndicatorDropdownPanel;
|
||||||
@@ -26,6 +26,8 @@ interface NotifyTopMenuBarProps {
|
|||||||
onTradeAlertPopupLayout?: (v: TradeAlertPopupLayout) => void;
|
onTradeAlertPopupLayout?: (v: TradeAlertPopupLayout) => void;
|
||||||
onTradeAlertPopupGridCols?: (v: number) => void;
|
onTradeAlertPopupGridCols?: (v: number) => void;
|
||||||
onFullscreen?: () => void;
|
onFullscreen?: () => void;
|
||||||
|
onOpenFloatingWidgets?: () => void;
|
||||||
|
floatingWidgetCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NotifyTopMenuBar: React.FC<NotifyTopMenuBarProps> = props => {
|
export const NotifyTopMenuBar: React.FC<NotifyTopMenuBarProps> = props => {
|
||||||
|
|||||||
@@ -1,38 +1,6 @@
|
|||||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import { INDICATOR_REGISTRY, type IndicatorDef } from '../utils/indicatorRegistry';
|
import IndicatorDropdownPanel from './IndicatorDropdownPanel';
|
||||||
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 type { ChartType, Theme, Timeframe, ChartMode, IndicatorConfig } from '../types';
|
import type { ChartType, Theme, Timeframe, ChartMode, IndicatorConfig } from '../types';
|
||||||
import { MarketSearchPanel } from './MarketSearchPanel';
|
import { MarketSearchPanel } from './MarketSearchPanel';
|
||||||
import { getKoreanName } from '../utils/marketNameCache';
|
import { getKoreanName } from '../utils/marketNameCache';
|
||||||
@@ -165,12 +133,6 @@ const IcPlus = () => (
|
|||||||
<line x1="7" y1="2" x2="7" y2="12"/><line x1="2" y1="7" x2="12" y2="7"/>
|
<line x1="7" y1="2" x2="7" y2="12"/><line x1="2" y1="7" x2="12" y2="7"/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
const IcGear = () => (
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4">
|
|
||||||
<circle cx="7" cy="7" r="2.3"/>
|
|
||||||
<path d="M7 1 L7.7 2.8 L9.8 2 L9.8 4.2 L11.8 5 L11 7 L11.8 9 L9.8 9.8 L9.8 12 L7.7 11.2 L7 13 L6.3 11.2 L4.2 12 L4.2 9.8 L2.2 9 L3 7 L2.2 5 L4.2 4.2 L4.2 2 L6.3 2.8 Z"/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
const IcCandlestick = () => (
|
const IcCandlestick = () => (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
<line x1="4" y1="2" x2="4" y2="5"/><rect x="2" y="5" width="4" height="5" rx="0.5" fill="currentColor" opacity="0.3"/><line x1="4" y1="10" x2="4" y2="14"/>
|
<line x1="4" y1="2" x2="4" y2="5"/><rect x="2" y="5" width="4" height="5" rx="0.5" fill="currentColor" opacity="0.3"/><line x1="4" y1="10" x2="4" y2="14"/>
|
||||||
@@ -298,12 +260,6 @@ const IcReplay = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── 기본 탭 (사용자 정의 탭은 localStorage) ───────────────────────────────
|
|
||||||
const BUILTIN_TABS: Array<{ key: IndicatorTabKey; label: string }> = [
|
|
||||||
{ key: 'All', label: '전체' },
|
|
||||||
{ key: 'Main', label: '주요지표' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── 차트 타입 아이콘 매핑 ─────────────────────────────────────────────────
|
// ─── 차트 타입 아이콘 매핑 ─────────────────────────────────────────────────
|
||||||
const ChartIcon: Record<ChartType, React.ReactNode> = {
|
const ChartIcon: Record<ChartType, React.ReactNode> = {
|
||||||
candlestick: <IcCandlestick />,
|
candlestick: <IcCandlestick />,
|
||||||
@@ -322,487 +278,6 @@ const CHART_TYPES: { value: ChartType; label: string }[] = [
|
|||||||
{ value: 'baseline', label: '기준선' },
|
{ 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<IndDropdownProps> = ({
|
|
||||||
activeIndicators, onAdd, onAddMany, onIndicatorOrderChange, onRemove, onOpenSettings, onClose,
|
|
||||||
}) => {
|
|
||||||
const [search, setSearch] = React.useState('');
|
|
||||||
const [category, setCategory] = React.useState<IndicatorTabKey>('All');
|
|
||||||
const [customTabs, setCustomTabs] = React.useState<IndicatorCustomTab[]>(() => loadCustomTabs());
|
|
||||||
const [mainOrderTick, setMainOrderTick] = React.useState(0);
|
|
||||||
const [customTabsRevision, setCustomTabsRevision] = React.useState(0);
|
|
||||||
const [draggingType, setDraggingType] = React.useState<string | null>(null);
|
|
||||||
const [dragOverType, setDragOverType] = React.useState<string | null>(null);
|
|
||||||
const [editorOpen, setEditorOpen] = React.useState(false);
|
|
||||||
const [editorMode, setEditorMode] = React.useState<'create' | 'edit'>('create');
|
|
||||||
const searchRef = useRef<HTMLInputElement>(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 (
|
|
||||||
// 배경 클릭 시 닫힘
|
|
||||||
<div className="ind-overlay" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
|
|
||||||
<div
|
|
||||||
ref={panelRef}
|
|
||||||
className="ind-panel"
|
|
||||||
style={{
|
|
||||||
...panelStyle,
|
|
||||||
zIndex: 2001,
|
|
||||||
cursor: dragging ? 'grabbing' : undefined,
|
|
||||||
}}
|
|
||||||
onMouseDown={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="gc-popup-header"
|
|
||||||
onPointerDown={onHeaderPointerDown}
|
|
||||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
|
||||||
>
|
|
||||||
<span className="gc-popup-title">지표 추가</span>
|
|
||||||
<div className="ind-panel-header-actions">
|
|
||||||
<button type="button" className="gc-popup-close" onClick={onClose} aria-label="닫기">✕</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ind-panel-header-btn"
|
|
||||||
onPointerDown={stopHeaderBtn}
|
|
||||||
onClick={openCreateEditor}
|
|
||||||
>
|
|
||||||
탭 추가
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ind-panel-header-btn"
|
|
||||||
disabled={!selectedCustomTab}
|
|
||||||
onPointerDown={stopHeaderBtn}
|
|
||||||
onClick={openEditEditor}
|
|
||||||
>
|
|
||||||
수정
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ind-panel-header-btn danger"
|
|
||||||
disabled={!selectedCustomTab}
|
|
||||||
onPointerDown={stopHeaderBtn}
|
|
||||||
onClick={handleDeleteTab}
|
|
||||||
>
|
|
||||||
삭제
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 검색창 */}
|
|
||||||
<div className="ind-panel-search-row">
|
|
||||||
<div className="ind-panel-search-wrap">
|
|
||||||
<IcSearch />
|
|
||||||
<input
|
|
||||||
ref={searchRef}
|
|
||||||
className="ind-panel-search"
|
|
||||||
placeholder={`지표 검색... (전체 ${INDICATOR_REGISTRY.length}개)`}
|
|
||||||
value={search}
|
|
||||||
onChange={e => { setSearch(e.target.value); setCategory('All'); }}
|
|
||||||
/>
|
|
||||||
{search && (
|
|
||||||
<button className="ind-panel-clear" onClick={() => setSearch('')}>✕</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 카테고리 탭 */}
|
|
||||||
<div className="ind-panel-cats-row">
|
|
||||||
<div className="ind-panel-cats" role="tablist" aria-label="지표 탭">
|
|
||||||
{BUILTIN_TABS.map(c => {
|
|
||||||
const cnt = c.key === 'All'
|
|
||||||
? INDICATOR_REGISTRY.length
|
|
||||||
: MAIN_INDICATOR_TYPES.length;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={c.key}
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={category === c.key}
|
|
||||||
className={`ind-panel-cat${category === c.key ? ' active' : ''}`}
|
|
||||||
onClick={() => { setCategory(c.key); setSearch(''); }}
|
|
||||||
>
|
|
||||||
{c.label}
|
|
||||||
<span className="ind-panel-cat-cnt"> {cnt}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{customTabs.map(tab => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={category === customTabKey(tab.id)}
|
|
||||||
className={`ind-panel-cat${category === customTabKey(tab.id) ? ' active' : ''}`}
|
|
||||||
onClick={() => { setCategory(customTabKey(tab.id)); setSearch(''); }}
|
|
||||||
>
|
|
||||||
{tab.name}
|
|
||||||
<span className="ind-panel-cat-cnt"> {tab.indicatorTypes.length}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ind-panel-add-all"
|
|
||||||
disabled={addAllDisabled}
|
|
||||||
title={addAllDisabled ? '전체 탭에서는 사용할 수 없습니다' : '기존 보조지표를 제거하고 현재 탭 지표로 교체'}
|
|
||||||
aria-label="현재 탭 지표로 보조지표 교체"
|
|
||||||
onClick={handleAddAllInTab}
|
|
||||||
>
|
|
||||||
<IcPlus />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 지표 목록 */}
|
|
||||||
<div className="ind-panel-list">
|
|
||||||
{/* 활성 지표가 있을 때 상단에 표시 */}
|
|
||||||
{activeIndicators.length > 0 && !search && category === 'All' && (
|
|
||||||
<div className="ind-panel-section">
|
|
||||||
<div className="ind-panel-section-title">활성 지표 ({activeIndicators.length})</div>
|
|
||||||
{activeIndicators.map(a => {
|
|
||||||
const lbl = getIndicatorListLabels(a.type);
|
|
||||||
return (
|
|
||||||
<div key={a.id} className="ind-panel-item active">
|
|
||||||
<div className="ind-panel-item-info">
|
|
||||||
<span className="ind-panel-name">{lbl.ko}</span>
|
|
||||||
<span className="ind-panel-desc">{lbl.en}</span>
|
|
||||||
</div>
|
|
||||||
<div className="ind-panel-item-right">
|
|
||||||
<button className="ind-panel-rm" onClick={() => onRemove(a.type)}>제거</button>
|
|
||||||
{onOpenSettings && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ind-panel-settings"
|
|
||||||
title="설정"
|
|
||||||
aria-label={`${lbl.ko} 설정`}
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onOpenSettings(a.type);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IcGear />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div className="ind-panel-divider" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
|
||||||
<div className="ind-panel-empty">
|
|
||||||
{search
|
|
||||||
? `"${search}" 검색 결과 없음`
|
|
||||||
: isCustomTabKey(category)
|
|
||||||
? '이 탭에 표시할 지표가 없습니다. 수정 버튼으로 지표를 추가해 주세요.'
|
|
||||||
: '표시할 지표가 없습니다'}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
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 (
|
|
||||||
<div
|
|
||||||
key={def.type}
|
|
||||||
className={`ind-panel-item${active ? ' active' : ''}${isDragOver ? ' ind-panel-item--drag-over' : ''}`}
|
|
||||||
onDragOver={canReorderList ? e => {
|
|
||||||
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 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ind-settings-drag-handle ind-panel-drag-handle"
|
|
||||||
draggable
|
|
||||||
title="드래그하여 순서 변경"
|
|
||||||
aria-label="순서 변경"
|
|
||||||
onDragStart={e => {
|
|
||||||
setDraggingType(def.type);
|
|
||||||
e.dataTransfer.setData('text/indicator-type', def.type);
|
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
|
||||||
}}
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
⠿
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="ind-panel-item-info">
|
|
||||||
<span className="ind-panel-name">{mainLbl?.ko ?? def.description}</span>
|
|
||||||
<span className="ind-panel-desc">{mainLbl?.en ?? def.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="ind-panel-item-right">
|
|
||||||
{def.overlay && <span className="ind-panel-badge">오버레이</span>}
|
|
||||||
{active ? (
|
|
||||||
<button className="ind-panel-rm" onClick={() => onRemove(def.type)}>제거</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ind-panel-add"
|
|
||||||
title="차트에 추가"
|
|
||||||
onClick={() => {
|
|
||||||
onAdd(def.type);
|
|
||||||
syncChartToTabOrder();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IcPlus />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onOpenSettings && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ind-panel-settings"
|
|
||||||
title="설정"
|
|
||||||
aria-label={`${mainLbl?.ko ?? def.koreanName} 설정`}
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onOpenSettings(def.type);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IcGear />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{editorOpen && (
|
|
||||||
<IndicatorCustomTabEditor
|
|
||||||
mode={editorMode}
|
|
||||||
initialName={editorMode === 'edit' && selectedCustomTab ? selectedCustomTab.name : ''}
|
|
||||||
initialTypes={editorMode === 'edit' && selectedCustomTab ? selectedCustomTab.indicatorTypes : []}
|
|
||||||
onSave={handleEditorSave}
|
|
||||||
onClose={() => setEditorOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Toolbar ──────────────────────────────────────────────────────────────
|
// ─── Toolbar ──────────────────────────────────────────────────────────────
|
||||||
const Toolbar: React.FC<ToolbarProps> = ({
|
const Toolbar: React.FC<ToolbarProps> = ({
|
||||||
@@ -1038,7 +513,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
{/* 지표 패널은 fixed overlay로 렌더링 — 부모 overflow 영향 없음 */}
|
{/* 지표 패널은 fixed overlay로 렌더링 — 부모 overflow 영향 없음 */}
|
||||||
{showIndMenu && (
|
{showIndMenu && (
|
||||||
<IndDropdown
|
<IndicatorDropdownPanel
|
||||||
activeIndicators={activeIndicators}
|
activeIndicators={activeIndicators}
|
||||||
onAdd={type => onAddIndicator(type)}
|
onAdd={type => onAddIndicator(type)}
|
||||||
onAddMany={onAddIndicators}
|
onAddMany={onAddIndicators}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { AuthSession } from '../utils/auth';
|
|||||||
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
|
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
|
||||||
import { canAccessMenu } from '../utils/permissions';
|
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 {
|
interface TopMenuBarProps {
|
||||||
activePage: MenuPage;
|
activePage: MenuPage;
|
||||||
@@ -42,6 +42,10 @@ interface TopMenuBarProps {
|
|||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
/** 브라우저 전체화면 (모든 화면에서 사용) */
|
/** 브라우저 전체화면 (모든 화면에서 사용) */
|
||||||
onFullscreen?: () => void;
|
onFullscreen?: () => void;
|
||||||
|
/** 플로팅 위젯 레이아웃 선택 열기 */
|
||||||
|
onOpenFloatingWidgets?: () => void;
|
||||||
|
/** 실행 중인 플로팅 위젯 창 수 */
|
||||||
|
floatingWidgetCount?: number;
|
||||||
/** 메뉴별 접근 허용 (없으면 전체 표시) */
|
/** 메뉴별 접근 허용 (없으면 전체 표시) */
|
||||||
menuPermissions?: Record<string, boolean>;
|
menuPermissions?: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
@@ -196,8 +200,28 @@ const IcVerificationBoard = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const IcWidget = () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="1" y="1" width="6" height="6" rx="1"/>
|
||||||
|
<rect x="9" y="1" width="6" height="4" rx="1"/>
|
||||||
|
<rect x="1" y="9" width="6" height="6" rx="1"/>
|
||||||
|
<rect x="9" y="7" width="6" height="8" rx="1"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IcFloatingWidgetAdd = () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="1" y="1" width="6" height="6" rx="1"/>
|
||||||
|
<rect x="9" y="1" width="6" height="6" rx="1"/>
|
||||||
|
<rect x="1" y="9" width="6" height="6" rx="1"/>
|
||||||
|
<line x1="12" y1="12" x2="12" y2="15"/>
|
||||||
|
<line x1="10.5" y1="13.5" x2="13.5" y2="13.5"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
|
const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
|
||||||
{ page: 'dashboard', label: '대시보드', icon: <IcDashboard /> },
|
{ page: 'dashboard', label: '대시보드', icon: <IcDashboard /> },
|
||||||
|
{ page: 'widget-dashboard', label: '위젯', icon: <IcWidget /> },
|
||||||
{ page: 'chart', label: '실시간차트', icon: <IcChart /> },
|
{ page: 'chart', label: '실시간차트', icon: <IcChart /> },
|
||||||
{ page: 'paper', label: '투자관리', icon: <IcPaper /> },
|
{ page: 'paper', label: '투자관리', icon: <IcPaper /> },
|
||||||
{ page: 'virtual', label: '가상매매', icon: <IcVirtual /> },
|
{ page: 'virtual', label: '가상매매', icon: <IcVirtual /> },
|
||||||
@@ -228,6 +252,8 @@ export const TopMenuBar = memo(function TopMenuBar({
|
|||||||
onLoginClick,
|
onLoginClick,
|
||||||
onLogout,
|
onLogout,
|
||||||
onFullscreen,
|
onFullscreen,
|
||||||
|
onOpenFloatingWidgets,
|
||||||
|
floatingWidgetCount = 0,
|
||||||
menuPermissions,
|
menuPermissions,
|
||||||
}: TopMenuBarProps) {
|
}: TopMenuBarProps) {
|
||||||
const [isFullscreen, setIsFullscreen] = useState(() => !!document.fullscreenElement);
|
const [isFullscreen, setIsFullscreen] = useState(() => !!document.fullscreenElement);
|
||||||
@@ -281,6 +307,25 @@ export const TopMenuBar = memo(function TopMenuBar({
|
|||||||
|
|
||||||
{/* 우측 영역 */}
|
{/* 우측 영역 */}
|
||||||
<div className="tmb-right">
|
<div className="tmb-right">
|
||||||
|
{onOpenFloatingWidgets && (
|
||||||
|
<div className="tmb-floating-widget-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tmb-floating-widget-btn"
|
||||||
|
onClick={onOpenFloatingWidgets}
|
||||||
|
title="플로팅 위젯 추가"
|
||||||
|
aria-label="플로팅 위젯 추가"
|
||||||
|
>
|
||||||
|
<IcFloatingWidgetAdd />
|
||||||
|
</button>
|
||||||
|
{floatingWidgetCount > 0 && (
|
||||||
|
<span className="tmb-floating-widget-count" aria-hidden>
|
||||||
|
{floatingWidgetCount > 9 ? '9+' : floatingWidgetCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{onOpenAppDownload && (
|
{onOpenAppDownload && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { Theme } from '../types';
|
||||||
|
import {
|
||||||
|
loadPaperSummary,
|
||||||
|
loadPaperTrades,
|
||||||
|
loadStrategies,
|
||||||
|
type PaperSummaryDto,
|
||||||
|
type PaperTradeDto,
|
||||||
|
type StrategyDto,
|
||||||
|
} from '../utils/backendApi';
|
||||||
|
import {
|
||||||
|
flushPendingUiPreferences,
|
||||||
|
getUiPreferences,
|
||||||
|
patchUiPreferences,
|
||||||
|
subscribeUiPreferences,
|
||||||
|
} from '../utils/uiPreferencesDb';
|
||||||
|
import { defaultWidgetLayout, normalizeWidgetLayout, type WidgetLayoutSchema } from '../types/widgetDashboard';
|
||||||
|
import { WidgetDashboardProvider } from '../widgets/WidgetDashboardContext';
|
||||||
|
import WidgetDashboardShell from './widgetDashboard/WidgetDashboardShell';
|
||||||
|
import { PAPER_TRADES_CHANGED_EVENT } from '../utils/paperTradeEvents';
|
||||||
|
import { useMarketTicker } from '../hooks/useMarketTicker';
|
||||||
|
import type { ChartRealtimeSource } from '../hooks/useChartRealtimeData';
|
||||||
|
import { useChartWorkspaceContext } from '../chart/ChartWorkspaceContext';
|
||||||
|
import { useAppSettings } from '../hooks/useAppSettings';
|
||||||
|
import '../styles/widgetDashboard.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
theme: Theme;
|
||||||
|
paperTradingEnabled: boolean;
|
||||||
|
paperAutoTradeEnabled: boolean;
|
||||||
|
onPaperOrderFilled?: () => void;
|
||||||
|
chartRealtimeSource?: ChartRealtimeSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readWidgetLayoutFromPrefs(): WidgetLayoutSchema {
|
||||||
|
return normalizeWidgetLayout(getUiPreferences().widgetDashboard ?? defaultWidgetLayout());
|
||||||
|
}
|
||||||
|
|
||||||
|
const WidgetDashboardPage: React.FC<Props> = ({
|
||||||
|
theme,
|
||||||
|
paperTradingEnabled,
|
||||||
|
paperAutoTradeEnabled,
|
||||||
|
onPaperOrderFilled,
|
||||||
|
chartRealtimeSource = 'BACKEND_STOMP',
|
||||||
|
}) => {
|
||||||
|
const { isLoaded: appSettingsLoaded } = useAppSettings();
|
||||||
|
const { symbol: defaultMarket } = useChartWorkspaceContext();
|
||||||
|
const { tickers, marketInfos, loading: marketLoading, usdRate } = useMarketTicker(
|
||||||
|
useMemo(() => ({ enabled: true, loadFull: true }), []),
|
||||||
|
);
|
||||||
|
const [layout, setLayout] = useState<WidgetLayoutSchema>(defaultWidgetLayout);
|
||||||
|
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
||||||
|
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
|
||||||
|
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
|
||||||
|
const layoutJsonRef = useRef('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!appSettingsLoaded) return;
|
||||||
|
const next = readWidgetLayoutFromPrefs();
|
||||||
|
const json = JSON.stringify(next);
|
||||||
|
if (json !== layoutJsonRef.current) {
|
||||||
|
layoutJsonRef.current = json;
|
||||||
|
setLayout(next);
|
||||||
|
}
|
||||||
|
}, [appSettingsLoaded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!appSettingsLoaded) return;
|
||||||
|
return subscribeUiPreferences(() => {
|
||||||
|
const next = readWidgetLayoutFromPrefs();
|
||||||
|
const json = JSON.stringify(next);
|
||||||
|
if (json === layoutJsonRef.current) return;
|
||||||
|
layoutJsonRef.current = json;
|
||||||
|
setLayout(next);
|
||||||
|
});
|
||||||
|
}, [appSettingsLoaded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const flush = () => { flushPendingUiPreferences(); };
|
||||||
|
window.addEventListener('beforeunload', flush);
|
||||||
|
window.addEventListener('pagehide', flush);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeunload', flush);
|
||||||
|
window.removeEventListener('pagehide', flush);
|
||||||
|
flush();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const persistLayout = useCallback((
|
||||||
|
next: WidgetLayoutSchema,
|
||||||
|
opts?: { immediate?: boolean },
|
||||||
|
) => {
|
||||||
|
const normalized = normalizeWidgetLayout(next);
|
||||||
|
layoutJsonRef.current = JSON.stringify(normalized);
|
||||||
|
setLayout(normalized);
|
||||||
|
if (!appSettingsLoaded) return;
|
||||||
|
patchUiPreferences({ widgetDashboard: normalized }, opts?.immediate ?? false);
|
||||||
|
}, [appSettingsLoaded]);
|
||||||
|
|
||||||
|
const refreshPaperData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [sum, tr] = await Promise.all([loadPaperSummary(), loadPaperTrades()]);
|
||||||
|
setSummary(sum);
|
||||||
|
setTrades(tr ?? []);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadStrategies().then(list => setStrategies(list ?? []));
|
||||||
|
void refreshPaperData();
|
||||||
|
}, [refreshPaperData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onChanged = () => { void refreshPaperData(); };
|
||||||
|
window.addEventListener(PAPER_TRADES_CHANGED_EVENT, onChanged);
|
||||||
|
return () => window.removeEventListener(PAPER_TRADES_CHANGED_EVENT, onChanged);
|
||||||
|
}, [refreshPaperData]);
|
||||||
|
|
||||||
|
if (!appSettingsLoaded) {
|
||||||
|
return <div className="wd-loading">위젯 대시보드 로딩…</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WidgetDashboardProvider
|
||||||
|
theme={theme}
|
||||||
|
tickers={tickers}
|
||||||
|
marketInfos={marketInfos}
|
||||||
|
marketLoading={marketLoading}
|
||||||
|
usdRate={usdRate}
|
||||||
|
defaultMarket={defaultMarket}
|
||||||
|
strategies={strategies}
|
||||||
|
summary={summary}
|
||||||
|
trades={trades}
|
||||||
|
refreshPaperData={() => { void refreshPaperData(); }}
|
||||||
|
paperTradingEnabled={paperTradingEnabled}
|
||||||
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||||
|
onPaperOrderFilled={onPaperOrderFilled}
|
||||||
|
chartRealtimeSource={chartRealtimeSource}
|
||||||
|
>
|
||||||
|
<WidgetDashboardShell
|
||||||
|
theme={theme}
|
||||||
|
layout={layout}
|
||||||
|
onLayoutChange={persistLayout}
|
||||||
|
/>
|
||||||
|
</WidgetDashboardProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WidgetDashboardPage;
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* 플로팅 위젯 레이어 — 메뉴바에서 다중 팝업 위젯 실행
|
||||||
|
*/
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import type { Theme } from '../../types';
|
||||||
|
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
||||||
|
import {
|
||||||
|
loadPaperSummary,
|
||||||
|
loadPaperTrades,
|
||||||
|
loadStrategies,
|
||||||
|
type PaperSummaryDto,
|
||||||
|
type PaperTradeDto,
|
||||||
|
type StrategyDto,
|
||||||
|
} from '../../utils/backendApi';
|
||||||
|
import { PAPER_TRADES_CHANGED_EVENT } from '../../utils/paperTradeEvents';
|
||||||
|
import { useMarketTicker } from '../../hooks/useMarketTicker';
|
||||||
|
import { WidgetDashboardProvider } from '../../widgets/WidgetDashboardContext';
|
||||||
|
import {
|
||||||
|
createFloatingWidgetInstance,
|
||||||
|
type FloatingWidgetInstance,
|
||||||
|
type FloatingWidgetLayoutPreset,
|
||||||
|
} from '../../types/floatingWidget';
|
||||||
|
import type { WidgetSlot } from '../../types/widgetDashboard';
|
||||||
|
import FloatingWidgetLayoutPicker from './FloatingWidgetLayoutPicker';
|
||||||
|
import FloatingWidgetWindow from './FloatingWidgetWindow';
|
||||||
|
import '../../styles/floatingWidget.css';
|
||||||
|
|
||||||
|
const BASE_Z = 13000;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
theme: Theme;
|
||||||
|
defaultMarket: string;
|
||||||
|
layoutPickerOpen: boolean;
|
||||||
|
onLayoutPickerOpenChange: (open: boolean) => void;
|
||||||
|
paperTradingEnabled: boolean;
|
||||||
|
paperAutoTradeEnabled: boolean;
|
||||||
|
onPaperOrderFilled?: () => void;
|
||||||
|
chartRealtimeSource?: ChartRealtimeSource;
|
||||||
|
onInstancesChange?: (count: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FloatingWidgetLayer: React.FC<Props> = ({
|
||||||
|
theme,
|
||||||
|
defaultMarket,
|
||||||
|
layoutPickerOpen,
|
||||||
|
onLayoutPickerOpenChange,
|
||||||
|
paperTradingEnabled,
|
||||||
|
paperAutoTradeEnabled,
|
||||||
|
onPaperOrderFilled,
|
||||||
|
chartRealtimeSource = 'BACKEND_STOMP',
|
||||||
|
onInstancesChange,
|
||||||
|
}) => {
|
||||||
|
const [instances, setInstances] = useState<FloatingWidgetInstance[]>([]);
|
||||||
|
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||||
|
const zCounterRef = useRef(BASE_Z + 100);
|
||||||
|
|
||||||
|
const { tickers, marketInfos, loading: marketLoading, usdRate } = useMarketTicker(
|
||||||
|
useMemo(() => ({ enabled: true, loadFull: true }), []),
|
||||||
|
);
|
||||||
|
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
||||||
|
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
|
||||||
|
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
|
||||||
|
|
||||||
|
const refreshPaperData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [sum, tr] = await Promise.all([loadPaperSummary(), loadPaperTrades()]);
|
||||||
|
setSummary(sum);
|
||||||
|
setTrades(tr ?? []);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadStrategies().then(list => setStrategies(list ?? []));
|
||||||
|
void refreshPaperData();
|
||||||
|
}, [refreshPaperData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onChanged = () => { void refreshPaperData(); };
|
||||||
|
window.addEventListener(PAPER_TRADES_CHANGED_EVENT, onChanged);
|
||||||
|
return () => window.removeEventListener(PAPER_TRADES_CHANGED_EVENT, onChanged);
|
||||||
|
}, [refreshPaperData]);
|
||||||
|
|
||||||
|
const bringToFront = useCallback((id: string) => {
|
||||||
|
zCounterRef.current += 1;
|
||||||
|
const nextZ = zCounterRef.current;
|
||||||
|
setFocusedId(id);
|
||||||
|
setInstances(prev => prev.map(inst =>
|
||||||
|
inst.id === id ? { ...inst, zIndex: nextZ } : inst,
|
||||||
|
));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLayoutSelect = useCallback((preset: FloatingWidgetLayoutPreset) => {
|
||||||
|
zCounterRef.current += 1;
|
||||||
|
const index = instances.length;
|
||||||
|
const inst = createFloatingWidgetInstance(
|
||||||
|
preset.rows,
|
||||||
|
preset.cols,
|
||||||
|
index,
|
||||||
|
zCounterRef.current,
|
||||||
|
);
|
||||||
|
inst.zIndex = zCounterRef.current;
|
||||||
|
setInstances(prev => [...prev, inst]);
|
||||||
|
setFocusedId(inst.id);
|
||||||
|
}, [instances.length]);
|
||||||
|
|
||||||
|
const handleClose = useCallback((id: string) => {
|
||||||
|
setInstances(prev => prev.filter(i => i.id !== id));
|
||||||
|
setFocusedId(prev => (prev === id ? null : prev));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUpdateSlots = useCallback((id: string, slots: WidgetSlot[]) => {
|
||||||
|
setInstances(prev => prev.map(inst => (inst.id === id ? { ...inst, slots } : inst)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUpdateSize = useCallback((id: string, width: number, height: number) => {
|
||||||
|
setInstances(prev => prev.map(inst => (
|
||||||
|
inst.id === id ? { ...inst, width, height } : inst
|
||||||
|
)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onInstancesChange?.(instances.length);
|
||||||
|
}, [instances.length, onInstancesChange]);
|
||||||
|
|
||||||
|
if (instances.length === 0 && !layoutPickerOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WidgetDashboardProvider
|
||||||
|
theme={theme}
|
||||||
|
tickers={tickers}
|
||||||
|
marketInfos={marketInfos}
|
||||||
|
marketLoading={marketLoading}
|
||||||
|
usdRate={usdRate}
|
||||||
|
defaultMarket={defaultMarket}
|
||||||
|
strategies={strategies}
|
||||||
|
summary={summary}
|
||||||
|
trades={trades}
|
||||||
|
refreshPaperData={() => { void refreshPaperData(); }}
|
||||||
|
paperTradingEnabled={paperTradingEnabled}
|
||||||
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||||
|
onPaperOrderFilled={onPaperOrderFilled}
|
||||||
|
chartRealtimeSource={chartRealtimeSource}
|
||||||
|
>
|
||||||
|
{createPortal(
|
||||||
|
<div className="fw-layer" aria-label="플로팅 위젯">
|
||||||
|
{instances.map(inst => (
|
||||||
|
<FloatingWidgetWindow
|
||||||
|
key={inst.id}
|
||||||
|
instance={inst}
|
||||||
|
focused={focusedId === inst.id}
|
||||||
|
onClose={() => handleClose(inst.id)}
|
||||||
|
onFocus={() => bringToFront(inst.id)}
|
||||||
|
onUpdateSlots={slots => handleUpdateSlots(inst.id, slots)}
|
||||||
|
onUpdateSize={(w, h) => handleUpdateSize(inst.id, w, h)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<FloatingWidgetLayoutPicker
|
||||||
|
open={layoutPickerOpen}
|
||||||
|
onClose={() => onLayoutPickerOpenChange(false)}
|
||||||
|
onSelect={handleLayoutSelect}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</WidgetDashboardProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FloatingWidgetLayer;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import AppPopup from '../AppPopup';
|
||||||
|
import {
|
||||||
|
FLOATING_WIDGET_LAYOUTS,
|
||||||
|
type FloatingWidgetLayoutPreset,
|
||||||
|
} from '../../types/floatingWidget';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (preset: FloatingWidgetLayoutPreset) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FloatingWidgetLayoutPicker: React.FC<Props> = ({ open, onClose, onSelect }) => {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppPopup
|
||||||
|
onClose={onClose}
|
||||||
|
title="위젯 레이아웃"
|
||||||
|
titleKo="Floating Widget"
|
||||||
|
width={520}
|
||||||
|
maxWidth="96vw"
|
||||||
|
backdrop
|
||||||
|
closeOnBackdrop
|
||||||
|
zIndex={13100}
|
||||||
|
bodyClassName="wd-picker-popup-body"
|
||||||
|
>
|
||||||
|
<p className="wd-layout-intro">
|
||||||
|
원하는 그리드 형태를 선택하세요. 각 영역에서 + 버튼으로 위젯을 추가할 수 있으며,
|
||||||
|
여러 위젯 창을 동시에 띄울 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<div className="fw-layout-picker-grid">
|
||||||
|
{FLOATING_WIDGET_LAYOUTS.map(preset => (
|
||||||
|
<button
|
||||||
|
key={preset.id}
|
||||||
|
type="button"
|
||||||
|
className="fw-layout-option"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(preset);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="fw-layout-preview"
|
||||||
|
style={{
|
||||||
|
gridTemplateRows: `repeat(${preset.rows}, 1fr)`,
|
||||||
|
gridTemplateColumns: `repeat(${preset.cols}, 1fr)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: preset.rows * preset.cols }).map((_, i) => (
|
||||||
|
<span key={i} className="fw-layout-preview-cell" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="fw-layout-label">{preset.label}</span>
|
||||||
|
<span className="fw-layout-desc">{preset.rows * preset.cols}칸</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AppPopup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FloatingWidgetLayoutPicker;
|
||||||
@@ -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<Props> = ({
|
||||||
|
instance,
|
||||||
|
focused,
|
||||||
|
onClose,
|
||||||
|
onFocus,
|
||||||
|
onUpdateSlots,
|
||||||
|
onUpdateSize,
|
||||||
|
}) => {
|
||||||
|
const [pickSlotId, setPickSlotId] = useState<string | null>(null);
|
||||||
|
const bodyRef = useRef<HTMLDivElement>(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<typeof setTimeout> | 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(
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className={`fw-window${focused ? ' fw-window--focused' : ''}`}
|
||||||
|
style={{
|
||||||
|
...panelStyle,
|
||||||
|
width: instance.width,
|
||||||
|
height: instance.height,
|
||||||
|
zIndex: instance.zIndex,
|
||||||
|
cursor: dragging ? 'grabbing' : undefined,
|
||||||
|
}}
|
||||||
|
onMouseDown={onFocus}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="fw-window-head"
|
||||||
|
onPointerDown={onHeaderPointerDown}
|
||||||
|
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||||
|
>
|
||||||
|
<span className="fw-window-title">{title}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="fw-window-close"
|
||||||
|
title="위젯 창 닫기"
|
||||||
|
aria-label="위젯 창 닫기"
|
||||||
|
onPointerDown={e => e.stopPropagation()}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={bodyRef}
|
||||||
|
className="fw-window-body"
|
||||||
|
style={{
|
||||||
|
gridTemplateRows: `repeat(${instance.rows}, minmax(0, 1fr))`,
|
||||||
|
gridTemplateColumns: `repeat(${instance.cols}, minmax(0, 1fr))`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{instance.slots.map(slot => (
|
||||||
|
<div key={slot.id} className="fw-window-cell">
|
||||||
|
<WidgetSlotView
|
||||||
|
slot={slot}
|
||||||
|
zoneId="center"
|
||||||
|
onPickWidget={id => setPickSlotId(id)}
|
||||||
|
onClearWidget={id => clearWidget(id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fw-window-resize-layer">
|
||||||
|
<div
|
||||||
|
className="fw-window-resize-handle fw-window-resize-handle--sw"
|
||||||
|
role="separator"
|
||||||
|
aria-label="위젯 창 크기 조절 (좌하단)"
|
||||||
|
onPointerDown={onResizeSwPointerDown}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="fw-window-resize-handle fw-window-resize-handle--se"
|
||||||
|
role="separator"
|
||||||
|
aria-label="위젯 창 크기 조절 (우하단)"
|
||||||
|
onPointerDown={onResizeSePointerDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WidgetPickerModal
|
||||||
|
open={pickSlotId != null}
|
||||||
|
category="all"
|
||||||
|
onClose={() => setPickSlotId(null)}
|
||||||
|
onSelect={applyWidgetType}
|
||||||
|
/>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FloatingWidgetWindow;
|
||||||
@@ -53,6 +53,14 @@ export interface BuilderPageShellProps {
|
|||||||
collapsiblePanels?: boolean;
|
collapsiblePanels?: boolean;
|
||||||
leftCollapsedStorageKey?: string;
|
leftCollapsedStorageKey?: string;
|
||||||
rightCollapsedStorageKey?: string;
|
rightCollapsedStorageKey?: string;
|
||||||
|
/** 패널 크기·접기 변경 시 (위젯 대시보드 레이아웃 영속화 등) */
|
||||||
|
onPanelLayoutChange?: (patch: {
|
||||||
|
leftWidth?: number;
|
||||||
|
rightWidth?: number;
|
||||||
|
footerHeight?: number;
|
||||||
|
leftOpen?: boolean;
|
||||||
|
rightOpen?: boolean;
|
||||||
|
}) => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
loadingText?: string;
|
loadingText?: string;
|
||||||
}
|
}
|
||||||
@@ -129,6 +137,7 @@ export default function BuilderPageShell({
|
|||||||
collapsiblePanels = false,
|
collapsiblePanels = false,
|
||||||
leftCollapsedStorageKey,
|
leftCollapsedStorageKey,
|
||||||
rightCollapsedStorageKey,
|
rightCollapsedStorageKey,
|
||||||
|
onPanelLayoutChange,
|
||||||
loading = false,
|
loading = false,
|
||||||
loadingText = '로딩 중…',
|
loadingText = '로딩 중…',
|
||||||
}: BuilderPageShellProps) {
|
}: BuilderPageShellProps) {
|
||||||
@@ -158,7 +167,10 @@ export default function BuilderPageShell({
|
|||||||
() => leftWidthRef.current,
|
() => leftWidthRef.current,
|
||||||
LEFT_MIN,
|
LEFT_MIN,
|
||||||
LEFT_MAX,
|
LEFT_MAX,
|
||||||
v => storeSize(leftStorageKey, v),
|
v => {
|
||||||
|
storeSize(leftStorageKey, v);
|
||||||
|
onPanelLayoutChange?.({ leftWidth: v });
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRightSplitter = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
const handleRightSplitter = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
@@ -186,11 +198,12 @@ export default function BuilderPageShell({
|
|||||||
window.removeEventListener('pointermove', onMove);
|
window.removeEventListener('pointermove', onMove);
|
||||||
window.removeEventListener('pointerup', onUp);
|
window.removeEventListener('pointerup', onUp);
|
||||||
storeSize(rightStorageKey, rightWidthRef.current);
|
storeSize(rightStorageKey, rightWidthRef.current);
|
||||||
|
onPanelLayoutChange?.({ rightWidth: rightWidthRef.current });
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('pointermove', onMove);
|
window.addEventListener('pointermove', onMove);
|
||||||
window.addEventListener('pointerup', onUp);
|
window.addEventListener('pointerup', onUp);
|
||||||
}, [rightStorageKey]);
|
}, [rightStorageKey, onPanelLayoutChange]);
|
||||||
|
|
||||||
const onFooterSplitter = usePanelResize(
|
const onFooterSplitter = usePanelResize(
|
||||||
'horizontal',
|
'horizontal',
|
||||||
@@ -198,24 +211,29 @@ export default function BuilderPageShell({
|
|||||||
() => footerHeightRef.current,
|
() => footerHeightRef.current,
|
||||||
FOOTER_MIN,
|
FOOTER_MIN,
|
||||||
FOOTER_MAX,
|
FOOTER_MAX,
|
||||||
v => storeSize(footerStorageKey, v),
|
v => {
|
||||||
|
storeSize(footerStorageKey, v);
|
||||||
|
onPanelLayoutChange?.({ footerHeight: v });
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleLeft = useCallback(() => {
|
const toggleLeft = useCallback(() => {
|
||||||
setLeftOpen(prev => {
|
setLeftOpen(prev => {
|
||||||
const next = !prev;
|
const next = !prev;
|
||||||
if (leftCollapsedStorageKey) storeBool(leftCollapsedStorageKey, next);
|
if (leftCollapsedStorageKey) storeBool(leftCollapsedStorageKey, next);
|
||||||
|
onPanelLayoutChange?.({ leftOpen: next });
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [leftCollapsedStorageKey]);
|
}, [leftCollapsedStorageKey, onPanelLayoutChange]);
|
||||||
|
|
||||||
const toggleRight = useCallback(() => {
|
const toggleRight = useCallback(() => {
|
||||||
setRightOpen(prev => {
|
setRightOpen(prev => {
|
||||||
const next = !prev;
|
const next = !prev;
|
||||||
if (rightCollapsedStorageKey) storeBool(rightCollapsedStorageKey, next);
|
if (rightCollapsedStorageKey) storeBool(rightCollapsedStorageKey, next);
|
||||||
|
onPanelLayoutChange?.({ rightOpen: next });
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [rightCollapsedStorageKey]);
|
}, [rightCollapsedStorageKey, onPanelLayoutChange]);
|
||||||
|
|
||||||
const pageStyle = useMemo(() => ({
|
const pageStyle = useMemo(() => ({
|
||||||
'--bps-left-width': leftOpen ? `${leftWidth}px` : '0px',
|
'--bps-left-width': leftOpen ? `${leftWidth}px` : '0px',
|
||||||
|
|||||||
@@ -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<ZoneKey, { title: string; desc: string }> = {
|
||||||
|
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<WidgetZoneConfig>,
|
||||||
|
): 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 }) => (
|
||||||
|
<div className={`stg-row${className ? ` ${className}` : ''}`}>
|
||||||
|
<div className="stg-row-label">
|
||||||
|
<span className="stg-row-title">{label}</span>
|
||||||
|
{desc && <span className="stg-row-desc">{desc}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="stg-row-ctrl">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const LayoutEditorModal: React.FC<Props> = ({
|
||||||
|
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 (
|
||||||
|
<div key={key} className="stg-section wd-layout-section">
|
||||||
|
<div className="stg-section-title">{meta.title}</div>
|
||||||
|
<div className="stg-section-body">
|
||||||
|
{key !== 'center' && (
|
||||||
|
<SettingRow
|
||||||
|
label="패널 표시"
|
||||||
|
desc={meta.desc}
|
||||||
|
>
|
||||||
|
<label className="stg-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={e => setDraft(setZoneEnabled(draft, key, e.target.checked))}
|
||||||
|
/>
|
||||||
|
<span className="stg-toggle-slider" />
|
||||||
|
</label>
|
||||||
|
</SettingRow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{enabled && (
|
||||||
|
<>
|
||||||
|
<SettingRow
|
||||||
|
label="패널 구성"
|
||||||
|
desc="단일·상하 분할·탭 방식 중 선택"
|
||||||
|
className="stg-row--block"
|
||||||
|
>
|
||||||
|
<div className="stg-radio-row wd-layout-mode-row">
|
||||||
|
{MODE_OPTIONS.map(opt => (
|
||||||
|
<label
|
||||||
|
key={opt.value}
|
||||||
|
className="stg-radio-option stg-radio-option--blue"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`wd-zone-mode-${key}`}
|
||||||
|
checked={mode === opt.value}
|
||||||
|
onChange={() => setDraft(updateZoneConfig(
|
||||||
|
draft,
|
||||||
|
key,
|
||||||
|
{ mode: opt.value },
|
||||||
|
))}
|
||||||
|
/>
|
||||||
|
<span className="stg-radio-option-text">
|
||||||
|
<strong>{opt.label}</strong>
|
||||||
|
<span>{opt.desc}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
{mode === 'split' && (
|
||||||
|
<SettingRow
|
||||||
|
label="분할 수"
|
||||||
|
desc="상하로 배치할 위젯 칸 수"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
className="stg-select"
|
||||||
|
value={splitCount}
|
||||||
|
onChange={e => setDraft(updateZoneConfig(
|
||||||
|
draft,
|
||||||
|
key,
|
||||||
|
{ mode: 'split', splitCount: Number(e.target.value) as 1 | 2 | 3 },
|
||||||
|
))}
|
||||||
|
>
|
||||||
|
<option value={1}>1칸</option>
|
||||||
|
<option value={2}>2칸</option>
|
||||||
|
<option value={3}>3칸</option>
|
||||||
|
</select>
|
||||||
|
</SettingRow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'tabs' && (
|
||||||
|
<>
|
||||||
|
<div className="stg-subsection-label stg-subsection-label--spaced">
|
||||||
|
탭 목록
|
||||||
|
<span className="stg-subsection-desc">탭 이름과 탭 내부 분할 수를 설정합니다</span>
|
||||||
|
</div>
|
||||||
|
{(zone.tabs ?? []).map(tab => (
|
||||||
|
<div key={tab.id} className="stg-row stg-row--block wd-layout-tab-row">
|
||||||
|
<div className="wd-layout-tab-fields">
|
||||||
|
<label className="wd-layout-tab-field">
|
||||||
|
<span className="wd-layout-tab-field-label">탭 이름</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="stg-input stg-input--wide"
|
||||||
|
value={tab.label}
|
||||||
|
onChange={e => {
|
||||||
|
const z = draft.zones[key];
|
||||||
|
if (!z?.tabs) return;
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
zones: {
|
||||||
|
...draft.zones,
|
||||||
|
[key]: {
|
||||||
|
...z,
|
||||||
|
tabs: z.tabs.map(t =>
|
||||||
|
t.id === tab.id ? { ...t, label: e.target.value } : t,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="wd-layout-tab-field">
|
||||||
|
<span className="wd-layout-tab-field-label">분할</span>
|
||||||
|
<select
|
||||||
|
className="stg-select"
|
||||||
|
value={tab.splitCount}
|
||||||
|
onChange={e => {
|
||||||
|
const count = Number(e.target.value) as 1 | 2 | 3;
|
||||||
|
const z = draft.zones[key];
|
||||||
|
if (!z?.tabs) return;
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
zones: {
|
||||||
|
...draft.zones,
|
||||||
|
[key]: {
|
||||||
|
...z,
|
||||||
|
tabs: z.tabs.map(t =>
|
||||||
|
t.id === tab.id
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
splitCount: count,
|
||||||
|
slots: slotsForSplitCount(count, t.slots.map(s => s.widgetType)),
|
||||||
|
}
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value={1}>1칸</option>
|
||||||
|
<option value={2}>2칸</option>
|
||||||
|
<option value={3}>3칸</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{(zone.tabs?.length ?? 0) > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="stg-btn stg-btn--danger wd-layout-tab-delete"
|
||||||
|
onClick={() => {
|
||||||
|
const z = draft.zones[key];
|
||||||
|
if (!z) return;
|
||||||
|
setDraft(updateZoneConfig(draft, key, removeTabFromZone(z, tab.id)));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="wd-layout-tab-add">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="stg-btn-secondary wd-layout-tab-add-btn"
|
||||||
|
onClick={() => {
|
||||||
|
const z = draft.zones[key];
|
||||||
|
if (!z) return;
|
||||||
|
const n = (z.tabs?.length ?? 0) + 1;
|
||||||
|
setDraft(updateZoneConfig(draft, key, addTabToZone(z, `탭 ${n}`)));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ 탭 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppPopup
|
||||||
|
onClose={onClose}
|
||||||
|
title="화면 구성"
|
||||||
|
titleKo="Layout"
|
||||||
|
width={680}
|
||||||
|
maxWidth="96vw"
|
||||||
|
backdrop
|
||||||
|
closeOnBackdrop
|
||||||
|
zIndex={12000}
|
||||||
|
bodyClassName="wd-layout-popup-body"
|
||||||
|
footer={(
|
||||||
|
<div className="stg-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="stg-btn-reset"
|
||||||
|
onClick={() => setDraft(defaultWidgetLayout())}
|
||||||
|
>
|
||||||
|
기본값으로 초기화
|
||||||
|
</button>
|
||||||
|
<button type="button" className="stg-btn-save" onClick={apply}>
|
||||||
|
적용
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="wd-layout-intro">
|
||||||
|
좌·중·우·하단 패널의 표시 여부와 내부 구성을 설정합니다.
|
||||||
|
변경 내용은 저장 후 새로고침·다른 기기에서도 동일하게 복원됩니다.
|
||||||
|
</p>
|
||||||
|
{(['left', 'center', 'right', 'bottom'] as ZoneKey[]).map(renderZoneEditor)}
|
||||||
|
</AppPopup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LayoutEditorModal;
|
||||||
@@ -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<Props> = ({
|
||||||
|
theme,
|
||||||
|
layout,
|
||||||
|
onLayoutChange,
|
||||||
|
}) => {
|
||||||
|
const [layoutEditorOpen, setLayoutEditorOpen] = useState(false);
|
||||||
|
const [pickTarget, setPickTarget] = useState<PickTarget | null>(null);
|
||||||
|
const [activeTabByZone, setActiveTabByZone] = useState<Partial<Record<WidgetZoneId, string>>>({});
|
||||||
|
|
||||||
|
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<string, unknown> }) =>
|
||||||
|
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 },
|
||||||
|
) => (
|
||||||
|
<WidgetZoneView
|
||||||
|
zoneId={zoneId}
|
||||||
|
zone={zone}
|
||||||
|
onChange={z => 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,
|
||||||
|
) => (
|
||||||
|
<WidgetZoneTabBar
|
||||||
|
tabs={zone.tabs ?? []}
|
||||||
|
activeTabId={getActiveTab(zoneId, zone)}
|
||||||
|
onSelect={tabId => 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
|
||||||
|
? (
|
||||||
|
<WidgetSidePanel tabs={renderSideTabBar('left', leftZone)}>
|
||||||
|
{leftPanelContent}
|
||||||
|
</WidgetSidePanel>
|
||||||
|
)
|
||||||
|
: leftPanelContent;
|
||||||
|
|
||||||
|
const rightPanel = rightPanelContent && rightUsesTabs && rightZone
|
||||||
|
? (
|
||||||
|
<WidgetSidePanel tabs={renderSideTabBar('right', rightZone)}>
|
||||||
|
{rightPanelContent}
|
||||||
|
</WidgetSidePanel>
|
||||||
|
)
|
||||||
|
: rightPanelContent;
|
||||||
|
|
||||||
|
const centerPanel = renderZoneView('center', centerZone, { hideTabBar: centerUsesTabs });
|
||||||
|
|
||||||
|
const footerPanel = bottomZone
|
||||||
|
? (
|
||||||
|
<div className={`wd-footer-zone${bottomUsesTabs ? ' wd-footer-zone--tabs' : ''}`}>
|
||||||
|
{bottomUsesTabs && bottomZone && (
|
||||||
|
<WidgetZoneTabBar
|
||||||
|
tabs={bottomZone.tabs ?? []}
|
||||||
|
activeTabId={getActiveTab('bottom', bottomZone)}
|
||||||
|
onSelect={tabId => setActiveTab('bottom', tabId)}
|
||||||
|
variant="panel"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{renderZoneView('bottom', bottomZone, { hideTabBar: bottomUsesTabs })}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const centerHead = centerUsesTabs && centerZone
|
||||||
|
? (
|
||||||
|
<div className="wd-center-head-tabs">
|
||||||
|
<WidgetZoneTabBar
|
||||||
|
tabs={centerZone.tabs ?? []}
|
||||||
|
activeTabId={getActiveTab('center', centerZone)}
|
||||||
|
onSelect={tabId => setActiveTab('center', tabId)}
|
||||||
|
variant="panel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const headerActions = (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="wd-toolbar-btn"
|
||||||
|
onClick={() => setLayoutEditorOpen(true)}
|
||||||
|
>
|
||||||
|
화면 구성
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<BuilderPageShell
|
||||||
|
theme={theme}
|
||||||
|
title="위젯"
|
||||||
|
subtitle="Widget Dashboard"
|
||||||
|
pageClassName={pageClass}
|
||||||
|
headerActions={headerActions}
|
||||||
|
collapsiblePanels
|
||||||
|
leftStorageKey={storageKeys.left}
|
||||||
|
leftDefaultWidth={layout.panelSizes.leftWidth ?? 280}
|
||||||
|
leftCollapsedStorageKey={storageKeys.leftCollapsed}
|
||||||
|
rightStorageKey={storageKeys.right}
|
||||||
|
rightDefaultWidth={layout.panelSizes.rightWidth ?? 320}
|
||||||
|
rightCollapsedStorageKey={storageKeys.rightCollapsed}
|
||||||
|
footerStorageKey={storageKeys.footer}
|
||||||
|
onPanelLayoutChange={handlePanelLayoutChange}
|
||||||
|
left={leftPanel ?? <span />}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
<WidgetPickerModal
|
||||||
|
open={pickTarget != null}
|
||||||
|
category={pickCategory}
|
||||||
|
onClose={() => setPickTarget(null)}
|
||||||
|
onSelect={applyWidgetType}
|
||||||
|
/>
|
||||||
|
<LayoutEditorModal
|
||||||
|
open={layoutEditorOpen}
|
||||||
|
layout={layout}
|
||||||
|
onClose={() => setLayoutEditorOpen(false)}
|
||||||
|
onApply={draft => onLayoutChange(draft, { immediate: true })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WidgetDashboardShell;
|
||||||
|
|
||||||
|
export { defaultWidgetLayout };
|
||||||
@@ -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<Props> = ({
|
||||||
|
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 (
|
||||||
|
<AppPopup
|
||||||
|
onClose={onClose}
|
||||||
|
title={title}
|
||||||
|
titleKo="Widget"
|
||||||
|
width={620}
|
||||||
|
maxWidth="96vw"
|
||||||
|
backdrop
|
||||||
|
closeOnBackdrop
|
||||||
|
zIndex={13200}
|
||||||
|
bodyClassName="wd-picker-popup-body"
|
||||||
|
>
|
||||||
|
<p className="wd-layout-intro">{intro}</p>
|
||||||
|
{groups.map(g => (
|
||||||
|
<section key={g.group} className="wd-picker-section">
|
||||||
|
<h3 className="wd-picker-section-title">{g.label}</h3>
|
||||||
|
<div className="wd-picker-grid">
|
||||||
|
{g.items.map(w => (
|
||||||
|
<button
|
||||||
|
key={w.type}
|
||||||
|
type="button"
|
||||||
|
className="wd-picker-item"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(w.type);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="wd-picker-item-label">{w.label}</span>
|
||||||
|
<span className="wd-picker-item-desc">{w.description}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</AppPopup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WidgetPickerModal;
|
||||||
@@ -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 (
|
||||||
|
<div className="wd-side-panel">
|
||||||
|
{tabs}
|
||||||
|
<div className="wd-side-panel-body">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WidgetSidePanel;
|
||||||
@@ -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<Props> = ({
|
||||||
|
slot,
|
||||||
|
zoneId,
|
||||||
|
onPickWidget,
|
||||||
|
onClearWidget,
|
||||||
|
}) => {
|
||||||
|
const def = getWidgetDefinition(slot.widgetType);
|
||||||
|
const category = zoneId === 'center' ? 'center' : 'side';
|
||||||
|
const Host = def?.Host;
|
||||||
|
const filledEmbed = Boolean(Host);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'wd-slot',
|
||||||
|
filledEmbed ? 'wd-slot--filled' : '',
|
||||||
|
filledEmbed && zoneId === 'center' ? 'wd-slot--filled-center' : '',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
>
|
||||||
|
{!filledEmbed && (
|
||||||
|
<header className="wd-slot-head">
|
||||||
|
<span className="wd-slot-title">{widgetLabel(slot.widgetType)}</span>
|
||||||
|
<div className="wd-slot-actions">
|
||||||
|
{slot.widgetType && onClearWidget && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="wd-slot-action"
|
||||||
|
title="위젯 제거"
|
||||||
|
aria-label="위젯 제거"
|
||||||
|
onClick={() => onClearWidget(slot.id)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
<div className="wd-slot-body">
|
||||||
|
{Host ? (
|
||||||
|
<Host config={slot.config} />
|
||||||
|
) : (
|
||||||
|
<div className="wd-slot-empty">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="wd-slot-add"
|
||||||
|
title="위젯 추가"
|
||||||
|
aria-label="위젯 추가"
|
||||||
|
onClick={() => onPickWidget(slot.id)}
|
||||||
|
>
|
||||||
|
<span className="wd-slot-add-icon">+</span>
|
||||||
|
</button>
|
||||||
|
<p className="wd-slot-empty-hint">
|
||||||
|
{category === 'center' ? '메인 위젯을 추가하세요' : '패널 위젯을 추가하세요'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Host && (
|
||||||
|
<>
|
||||||
|
{filledEmbed && onClearWidget && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="wd-slot-overlay-clear"
|
||||||
|
title="위젯 제거"
|
||||||
|
aria-label="위젯 제거"
|
||||||
|
onClick={() => onClearWidget(slot.id)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="wd-slot-change"
|
||||||
|
title="위젯 변경"
|
||||||
|
aria-label="위젯 변경"
|
||||||
|
onClick={() => onPickWidget(slot.id)}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WidgetSlotView;
|
||||||
@@ -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<TabBarProps> = ({
|
||||||
|
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 (
|
||||||
|
<div className={wrapClass} role="tablist">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTabId === tab.id}
|
||||||
|
className={`se-right-tab${activeTabId === tab.id ? ' se-right-tab--on' : ''}`}
|
||||||
|
onClick={() => onSelect(tab.id)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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<Props> = ({
|
||||||
|
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) => (
|
||||||
|
<div
|
||||||
|
className={`wd-zone-slots wd-zone-slots--${slots.length}`}
|
||||||
|
data-split={slots.length}
|
||||||
|
>
|
||||||
|
{slots.map(slot => (
|
||||||
|
<WidgetSlotView
|
||||||
|
key={slot.id}
|
||||||
|
slot={slot}
|
||||||
|
zoneId={zoneId}
|
||||||
|
onPickWidget={id => onPickWidget(id, zoneId, tabId)}
|
||||||
|
onClearWidget={id => clearWidget(id, tabId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (zone.mode === 'tabs') {
|
||||||
|
const tabs = zone.tabs ?? [];
|
||||||
|
const activeTab = tabs.find(t => t.id === activeTabId) ?? tabs[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wd-zone wd-zone--tabs">
|
||||||
|
{!hideTabBar && (
|
||||||
|
<WidgetZoneTabBar
|
||||||
|
tabs={tabs}
|
||||||
|
activeTabId={activeTabId}
|
||||||
|
onSelect={id => onActiveTabChange?.(id)}
|
||||||
|
variant="side"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab && renderSlots(activeTab.slots, activeTab.id)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const slots = zone.slots ?? [];
|
||||||
|
return (
|
||||||
|
<div className={`wd-zone wd-zone--${zone.mode}`}>
|
||||||
|
{renderSlots(slots)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
type ChartPaneSeparatorOptions,
|
type ChartPaneSeparatorOptions,
|
||||||
} from '../types/chartPaneSeparator';
|
} from '../types/chartPaneSeparator';
|
||||||
import { migrateLocalStorageToUiPreferences } from '../utils/uiPreferencesMigrate';
|
import { migrateLocalStorageToUiPreferences } from '../utils/uiPreferencesMigrate';
|
||||||
|
import { reconcilePendingUiPreferencesOnLoad } from '../utils/uiPreferencesDb';
|
||||||
import { loadLocalTheme } from '../utils/localThemeStorage';
|
import { loadLocalTheme } from '../utils/localThemeStorage';
|
||||||
|
|
||||||
// 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지
|
// 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지
|
||||||
@@ -79,6 +80,11 @@ export function getAppSettingsCache(): AppSettingsDto {
|
|||||||
return _cache ?? {};
|
return _cache ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** DB에서 앱 설정 로드가 완료되었는지 (null 캐시 = 미로드) */
|
||||||
|
export function isAppSettingsCacheLoaded(): boolean {
|
||||||
|
return _cache !== null;
|
||||||
|
}
|
||||||
|
|
||||||
/** 낙관적 캐시 패치 — _cache 가 null 일 때도 다음 로드 전까지 임시 보관 */
|
/** 낙관적 캐시 패치 — _cache 가 null 일 때도 다음 로드 전까지 임시 보관 */
|
||||||
export function patchAppSettingsCache(patch: Partial<AppSettingsDto>): void {
|
export function patchAppSettingsCache(patch: Partial<AppSettingsDto>): void {
|
||||||
if (_cache !== null) {
|
if (_cache !== null) {
|
||||||
@@ -102,6 +108,7 @@ function ensureLoaded(): Promise<AppSettingsDto> {
|
|||||||
return _cache ?? {};
|
return _cache ?? {};
|
||||||
}
|
}
|
||||||
_cache = data ?? {};
|
_cache = data ?? {};
|
||||||
|
reconcilePendingUiPreferencesOnLoad();
|
||||||
const migrated = migrateLocalStorageToUiPreferences(_cache);
|
const migrated = migrateLocalStorageToUiPreferences(_cache);
|
||||||
if (migrated?.changed) {
|
if (migrated?.changed) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export function useDraggablePanel(options: UseDraggablePanelOptions = {}) {
|
|||||||
return {
|
return {
|
||||||
panelRef: internalRef,
|
panelRef: internalRef,
|
||||||
pos,
|
pos,
|
||||||
|
setPos,
|
||||||
dragging,
|
dragging,
|
||||||
/** @deprecated onHeaderPointerDown 사용 */
|
/** @deprecated onHeaderPointerDown 사용 */
|
||||||
onHeaderMouseDown: onHeaderPointerDown,
|
onHeaderMouseDown: onHeaderPointerDown,
|
||||||
|
|||||||
@@ -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<HTMLElement | null>,
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ export interface RecentTrade {
|
|||||||
time: number;
|
time: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_TRADES = 12;
|
const MAX_TRADES_DEFAULT = 12;
|
||||||
|
|
||||||
interface UpbitTradeRaw {
|
interface UpbitTradeRaw {
|
||||||
market: string;
|
market: string;
|
||||||
@@ -30,9 +30,11 @@ interface UpbitTradeWs {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpbitRecentTrades(market: string, enabled = true) {
|
export function useUpbitRecentTrades(market: string, enabled = true, maxTrades = MAX_TRADES_DEFAULT) {
|
||||||
const [trades, setTrades] = useState<RecentTrade[]>([]);
|
const [trades, setTrades] = useState<RecentTrade[]>([]);
|
||||||
const [strength, setStrength] = useState<number | null>(null);
|
const [strength, setStrength] = useState<number | null>(null);
|
||||||
|
const maxRef = useRef(maxTrades);
|
||||||
|
maxRef.current = maxTrades;
|
||||||
|
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
const marketRef = useRef(market);
|
const marketRef = useRef(market);
|
||||||
@@ -40,7 +42,8 @@ export function useUpbitRecentTrades(market: string, enabled = true) {
|
|||||||
|
|
||||||
const pushTrade = useCallback((t: RecentTrade) => {
|
const pushTrade = useCallback((t: RecentTrade) => {
|
||||||
setTrades(prev => {
|
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 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);
|
const sellVol = next.filter(x => x.side === 'ask').reduce((s, x) => s + x.volume, 0);
|
||||||
if (sellVol > 0) setStrength((buyVol / sellVol) * 100);
|
if (sellVol > 0) setStrength((buyVol / sellVol) * 100);
|
||||||
@@ -51,7 +54,7 @@ export function useUpbitRecentTrades(market: string, enabled = true) {
|
|||||||
|
|
||||||
const fetchSnapshot = useCallback(async () => {
|
const fetchSnapshot = useCallback(async () => {
|
||||||
try {
|
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;
|
if (!res.ok) return;
|
||||||
const data: UpbitTradeRaw[] = await res.json();
|
const data: UpbitTradeRaw[] = await res.json();
|
||||||
const list: RecentTrade[] = data.map(d => ({
|
const list: RecentTrade[] = data.map(d => ({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import type { VirtualCardViewMode, VirtualSessionConfig, VirtualTargetItem } fro
|
|||||||
import type { StrategyEditorMode } from '../utils/strategyEditorModeStorage';
|
import type { StrategyEditorMode } from '../utils/strategyEditorModeStorage';
|
||||||
import type { UserStrategyTemplate } from '../utils/strategyTemplateStorage';
|
import type { UserStrategyTemplate } from '../utils/strategyTemplateStorage';
|
||||||
import type { SidewaysFilterPaletteEntry } from '../utils/sidewaysFilterPaletteStorage';
|
import type { SidewaysFilterPaletteEntry } from '../utils/sidewaysFilterPaletteStorage';
|
||||||
|
import type { WidgetLayoutSchema } from './widgetDashboard';
|
||||||
|
|
||||||
/** gc_app_settings.ui_preferences_json 구조 */
|
/** gc_app_settings.ui_preferences_json 구조 */
|
||||||
export interface UiPreferences {
|
export interface UiPreferences {
|
||||||
@@ -54,6 +55,8 @@ export interface UiPreferences {
|
|||||||
/** 시간봉 필터 — 빈 문자열=전체, 그 외 candleType (1m, 5m, …) */
|
/** 시간봉 필터 — 빈 문자열=전체, 그 외 candleType (1m, 5m, …) */
|
||||||
candleFilter?: string;
|
candleFilter?: string;
|
||||||
};
|
};
|
||||||
|
/** 위젯 대시보드 — 단일 레이아웃 (기기 간 동기화) */
|
||||||
|
widgetDashboard?: WidgetLayoutSchema;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UI_PREFERENCES_VERSION = 1;
|
export const UI_PREFERENCES_VERSION = 1;
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<WidgetLayoutSchema>;
|
||||||
|
const zonesRaw = (o.zones ?? {}) as Partial<WidgetLayoutSchema['zones']>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ export type SettingsCategoryId =
|
|||||||
| 'trading' | 'paper' | 'virtual' | 'live' | 'trend-search' | 'alert' | 'network' | 'admin';
|
| 'trading' | 'paper' | 'virtual' | 'live' | 'trend-search' | 'alert' | 'network' | 'admin';
|
||||||
|
|
||||||
export const TOP_MENU_IDS: TopMenuId[] = [
|
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[] = [
|
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
||||||
@@ -21,7 +21,7 @@ export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const ALL_MENU_IDS = [
|
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),
|
...SETTINGS_MENU_IDS.map(id => `settings_${id}` as const),
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ export type MenuPermissionId = typeof ALL_MENU_IDS[number];
|
|||||||
|
|
||||||
export const MENU_LABELS: Record<string, string> = {
|
export const MENU_LABELS: Record<string, string> = {
|
||||||
dashboard: '대시보드',
|
dashboard: '대시보드',
|
||||||
|
'widget-dashboard': '위젯',
|
||||||
chart: '실시간차트',
|
chart: '실시간차트',
|
||||||
paper: '투자관리',
|
paper: '투자관리',
|
||||||
virtual: '가상매매',
|
virtual: '가상매매',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
getAppSettingsCache,
|
getAppSettingsCache,
|
||||||
|
isAppSettingsCacheLoaded,
|
||||||
patchAppSettingsCache,
|
patchAppSettingsCache,
|
||||||
subscribeAppSettings,
|
subscribeAppSettings,
|
||||||
} from '../hooks/useAppSettings';
|
} from '../hooks/useAppSettings';
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
UI_PREFERENCES_VERSION,
|
UI_PREFERENCES_VERSION,
|
||||||
type UiPreferences,
|
type UiPreferences,
|
||||||
} from '../types/uiPreferences';
|
} from '../types/uiPreferences';
|
||||||
|
import { normalizeWidgetLayout } from '../types/widgetDashboard';
|
||||||
|
|
||||||
const SAVE_DEBOUNCE_MS = 400;
|
const SAVE_DEBOUNCE_MS = 400;
|
||||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -51,6 +53,9 @@ export function resolveUiPreferences(raw: unknown): UiPreferences {
|
|||||||
panels: { ...EMPTY_UI_PREFERENCES.panels, ...o.panels },
|
panels: { ...EMPTY_UI_PREFERENCES.panels, ...o.panels },
|
||||||
virtual: { ...EMPTY_UI_PREFERENCES.virtual, ...o.virtual },
|
virtual: { ...EMPTY_UI_PREFERENCES.virtual, ...o.virtual },
|
||||||
tradeNotifications: { ...EMPTY_UI_PREFERENCES.tradeNotifications, ...o.tradeNotifications },
|
tradeNotifications: { ...EMPTY_UI_PREFERENCES.tradeNotifications, ...o.tradeNotifications },
|
||||||
|
widgetDashboard: o.widgetDashboard
|
||||||
|
? normalizeWidgetLayout(o.widgetDashboard)
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +66,13 @@ export function getUiPreferences(): UiPreferences {
|
|||||||
|
|
||||||
/** UI 설정 부분 갱신 — 낙관적 캐시 + 디바운스 DB 저장 */
|
/** UI 설정 부분 갱신 — 낙관적 캐시 + 디바운스 DB 저장 */
|
||||||
export function patchUiPreferences(patch: Partial<UiPreferences>, immediate = false): void {
|
export function patchUiPreferences(patch: Partial<UiPreferences>, immediate = false): void {
|
||||||
|
if (!isAppSettingsCacheLoaded()) {
|
||||||
|
pendingPatch = pendingPatch
|
||||||
|
? (deepMerge(pendingPatch as Record<string, unknown>, patch as Record<string, unknown>) as Partial<UiPreferences>)
|
||||||
|
: patch;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const current = getUiPreferences();
|
const current = getUiPreferences();
|
||||||
const next = deepMerge(current as Record<string, unknown>, patch as Record<string, unknown>) as UiPreferences;
|
const next = deepMerge(current as Record<string, unknown>, patch as Record<string, unknown>) as UiPreferences;
|
||||||
next.v = UI_PREFERENCES_VERSION;
|
next.v = UI_PREFERENCES_VERSION;
|
||||||
@@ -73,6 +85,7 @@ export function patchUiPreferences(patch: Partial<UiPreferences>, immediate = fa
|
|||||||
|
|
||||||
const flush = () => {
|
const flush = () => {
|
||||||
saveTimer = null;
|
saveTimer = null;
|
||||||
|
if (!isAppSettingsCacheLoaded()) return;
|
||||||
const toSave = pendingPatch
|
const toSave = pendingPatch
|
||||||
? resolveUiPreferences(deepMerge(getUiPreferences() as Record<string, unknown>, pendingPatch as Record<string, unknown>))
|
? resolveUiPreferences(deepMerge(getUiPreferences() as Record<string, unknown>, pendingPatch as Record<string, unknown>))
|
||||||
: next;
|
: next;
|
||||||
@@ -95,10 +108,36 @@ export function patchUiPreferences(patch: Partial<UiPreferences>, immediate = fa
|
|||||||
saveTimer = setTimeout(flush, SAVE_DEBOUNCE_MS);
|
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<string, unknown>, pendingPatch as Record<string, unknown>),
|
||||||
|
);
|
||||||
|
pendingPatch = null;
|
||||||
|
void saveAppSettings({ uiPreferences: toSave }).catch(err => {
|
||||||
|
console.warn('[uiPreferences] DB flush 저장 실패:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function subscribeUiPreferences(listener: () => void): () => void {
|
export function subscribeUiPreferences(listener: () => void): () => void {
|
||||||
return subscribeAppSettings(listener);
|
return subscribeAppSettings(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 앱 설정 DB 로드 직후 — 로드 전에 큐잉된 patch 병합 */
|
||||||
|
export function reconcilePendingUiPreferencesOnLoad(): void {
|
||||||
|
if (!isAppSettingsCacheLoaded() || !pendingPatch) return;
|
||||||
|
const merged = resolveUiPreferences(
|
||||||
|
deepMerge(getUiPreferences() as Record<string, unknown>, pendingPatch as Record<string, unknown>),
|
||||||
|
);
|
||||||
|
patchAppSettingsCache({ uiPreferences: merged });
|
||||||
|
pendingPatch = null;
|
||||||
|
}
|
||||||
|
|
||||||
/** 중첩 경로 헬퍼 */
|
/** 중첩 경로 헬퍼 */
|
||||||
export function getUiPref<K extends keyof UiPreferences>(key: K): UiPreferences[K] {
|
export function getUiPref<K extends keyof UiPreferences>(key: K): UiPreferences[K] {
|
||||||
return getUiPreferences()[key];
|
return getUiPreferences()[key];
|
||||||
|
|||||||
@@ -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<Props> = ({
|
||||||
|
market,
|
||||||
|
onMarketChange,
|
||||||
|
strategy,
|
||||||
|
theme = 'dark',
|
||||||
|
chartRealtimeSource = 'BACKEND_STOMP',
|
||||||
|
}) => {
|
||||||
|
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||||
|
const tfOptions = useMemo(() => resolveStrategyTimeframes(strategy), [strategy]);
|
||||||
|
const [timeframe, setTimeframe] = useState<Timeframe>(
|
||||||
|
() => resolveStrategyPrimaryTimeframe(strategy),
|
||||||
|
);
|
||||||
|
const [mode, setMode] = useState<ChartMode>('chart');
|
||||||
|
const [drawingTool, setDrawingTool] = useState('cursor');
|
||||||
|
const [drawings, setDrawings] = useState<Drawing[]>([]);
|
||||||
|
const [drawingsLocked, setDrawingsLocked] = useState(false);
|
||||||
|
const [drawingsVisible, setDrawingsVisible] = useState(true);
|
||||||
|
const [indicatorsVisible, setIndicatorsVisible] = useState(true);
|
||||||
|
const [magnetMode, setMagnetMode] = useState<'off' | 'weak' | 'strong'>('off');
|
||||||
|
const [snapToIndicator, setSnapToIndicator] = useState(false);
|
||||||
|
const [showMarketSearch, setShowMarketSearch] = useState(false);
|
||||||
|
const [showIndicatorPanel, setShowIndicatorPanel] = useState(false);
|
||||||
|
const [leftToolbarVisible, setLeftToolbarVisible] = useState(true);
|
||||||
|
const [rightToolbarVisible, setRightToolbarVisible] = useState(true);
|
||||||
|
const [focusedIndicatorId, setFocusedIndicatorId] = useState<string | null>(null);
|
||||||
|
const [overlayVisibility, setOverlayVisibility] = useState<ChartOverlayVisibility>(
|
||||||
|
() => ({ ...DEFAULT_CHART_OVERLAY_VISIBILITY }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const marketBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const managerRef = useRef<ChartManager | null>(null);
|
||||||
|
const canvasWrapRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const indicatorsRef = useRef<IndicatorConfig[]>([]);
|
||||||
|
const [chartReloadTick, setChartReloadTick] = useState(0);
|
||||||
|
const chartReloadTriggeredRef = useRef(false);
|
||||||
|
const pendingBarRef = useRef<OHLCVBar | null>(null);
|
||||||
|
const barsMarketRef = useRef<string | null>(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<IndicatorConfig[]>(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<HTMLElement>('.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<typeof setTimeout> | 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<string, number | string | boolean>,
|
||||||
|
): 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 (
|
||||||
|
<div className="wd-candle-chart">
|
||||||
|
<div className="wd-candle-chart-header">
|
||||||
|
<button
|
||||||
|
ref={marketBtnRef}
|
||||||
|
type="button"
|
||||||
|
className="wd-candle-chart-market-btn"
|
||||||
|
title="종목 검색"
|
||||||
|
onClick={() => setShowMarketSearch(v => !v)}
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden>
|
||||||
|
<circle cx="6" cy="6" r="4" />
|
||||||
|
<line x1="9.5" y1="9.5" x2="13" y2="13" />
|
||||||
|
</svg>
|
||||||
|
<span className="wd-candle-chart-market-ko">{getKoreanName(market)}</span>
|
||||||
|
<span className="wd-candle-chart-market-sym">{market}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{tfOptions.length > 1 && (
|
||||||
|
<div className="wd-candle-chart-tf-row">
|
||||||
|
{tfOptions.map(tf => (
|
||||||
|
<button
|
||||||
|
key={tf}
|
||||||
|
type="button"
|
||||||
|
className={`wd-candle-chart-tf${timeframe === tf ? ' wd-candle-chart-tf--on' : ''}`}
|
||||||
|
onClick={() => setTimeframe(tf)}
|
||||||
|
>
|
||||||
|
{tf}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="wd-candle-chart-header-actions">
|
||||||
|
<div className="wd-candle-chart-toolbar-toggles" role="group" aria-label="차트 툴바 표시">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`wd-candle-chart-toolbar-toggle${leftToolbarVisible ? ' wd-candle-chart-toolbar-toggle--on' : ''}`}
|
||||||
|
title={leftToolbarVisible ? '좌측 툴바 숨기기' : '좌측 툴바 표시'}
|
||||||
|
aria-label={leftToolbarVisible ? '좌측 툴바 숨기기' : '좌측 툴바 표시'}
|
||||||
|
aria-pressed={leftToolbarVisible}
|
||||||
|
onClick={() => setLeftToolbarVisible(v => !v)}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden>
|
||||||
|
<rect x="1.5" y="2" width="3.5" height="10" rx="0.8" fill="currentColor" fillOpacity={leftToolbarVisible ? 0.35 : 0.12} />
|
||||||
|
<rect x="6" y="2" width="6.5" height="10" rx="0.8" strokeOpacity={0.55} />
|
||||||
|
</svg>
|
||||||
|
<span>좌</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`wd-candle-chart-toolbar-toggle${rightToolbarVisible ? ' wd-candle-chart-toolbar-toggle--on' : ''}`}
|
||||||
|
title={rightToolbarVisible ? '우측 툴바 숨기기' : '우측 툴바 표시'}
|
||||||
|
aria-label={rightToolbarVisible ? '우측 툴바 숨기기' : '우측 툴바 표시'}
|
||||||
|
aria-pressed={rightToolbarVisible}
|
||||||
|
onClick={() => setRightToolbarVisible(v => !v)}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden>
|
||||||
|
<rect x="6" y="2" width="6.5" height="10" rx="0.8" fill="currentColor" fillOpacity={rightToolbarVisible ? 0.35 : 0.12} />
|
||||||
|
<rect x="1.5" y="2" width="3.5" height="10" rx="0.8" strokeOpacity={0.55} />
|
||||||
|
</svg>
|
||||||
|
<span>우</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`wd-candle-chart-ind-btn${showIndicatorPanel ? ' wd-candle-chart-ind-btn--on' : ''}`}
|
||||||
|
title="보조지표 추가·제거"
|
||||||
|
onClick={() => setShowIndicatorPanel(v => !v)}
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
|
||||||
|
<polyline points="2,10 5,4 8,8 12,2" />
|
||||||
|
</svg>
|
||||||
|
<span>지표</span>
|
||||||
|
{indicators.length > 0 && (
|
||||||
|
<span className="wd-candle-chart-ind-badge">{indicators.length}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="wd-candle-chart-mode-btn"
|
||||||
|
title={mode === 'chart' ? '매매 모드' : '차트 모드'}
|
||||||
|
onClick={() => setMode(m => (m === 'chart' ? 'trading' : 'chart'))}
|
||||||
|
>
|
||||||
|
{mode === 'chart' ? '차트' : '매매'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="wd-candle-chart-main">
|
||||||
|
{leftToolbarVisible && (
|
||||||
|
<DrawingToolbar
|
||||||
|
activeTool={drawingTool}
|
||||||
|
mode={mode}
|
||||||
|
onTool={setDrawingTool}
|
||||||
|
onClearAll={handleClearDrawings}
|
||||||
|
onUndo={() => 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([])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={canvasWrapRef}
|
||||||
|
className="wd-candle-chart-canvas slot-chart-wrap vtd-card-chart-canvas--fill"
|
||||||
|
>
|
||||||
|
{isLoading && <div className="vtd-card-chart-loading">차트 로딩…</div>}
|
||||||
|
{isLoadingMore && (
|
||||||
|
<div className="chart-history-loading">
|
||||||
|
<div className="loading-spinner" style={{ width: 14, height: 14 }} />
|
||||||
|
<span>과거 데이터 로딩…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && !barsReady && (
|
||||||
|
<div className="vtd-card-chart-loading">캔들 데이터 수집 중…</div>
|
||||||
|
)}
|
||||||
|
<TradingChart
|
||||||
|
key={`${market}-${timeframe}-${chartReloadTick}-${effectiveIndicators.map(i => 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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showMarketSearch && marketBtnRef.current && ReactDOM.createPortal(
|
||||||
|
<MarketSearchPanel
|
||||||
|
currentMarket={market}
|
||||||
|
onSelect={m => {
|
||||||
|
onMarketChange(m);
|
||||||
|
setShowMarketSearch(false);
|
||||||
|
}}
|
||||||
|
onClose={() => setShowMarketSearch(false)}
|
||||||
|
anchorRect={marketBtnRef.current.getBoundingClientRect()}
|
||||||
|
anchorPlacement="dropdown"
|
||||||
|
/>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showIndicatorPanel && ReactDOM.createPortal(
|
||||||
|
<IndicatorDropdownPanel
|
||||||
|
activeIndicators={indicators}
|
||||||
|
onAdd={handleAddIndicator}
|
||||||
|
onAddMany={handleAddIndicators}
|
||||||
|
onIndicatorOrderChange={handleIndicatorListOrderChange}
|
||||||
|
onRemove={handleRemoveIndicatorByType}
|
||||||
|
onClose={() => setShowIndicatorPanel(false)}
|
||||||
|
/>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WidgetCandleChartPanel;
|
||||||
@@ -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<string, TickerData>;
|
||||||
|
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<WidgetDashboardContextValue | null>(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<string, TickerData>;
|
||||||
|
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<ProviderProps> = ({
|
||||||
|
theme,
|
||||||
|
tickers,
|
||||||
|
marketInfos,
|
||||||
|
marketLoading,
|
||||||
|
usdRate,
|
||||||
|
defaultMarket,
|
||||||
|
strategies,
|
||||||
|
summary,
|
||||||
|
trades,
|
||||||
|
refreshPaperData,
|
||||||
|
paperTradingEnabled,
|
||||||
|
paperAutoTradeEnabled,
|
||||||
|
onPaperOrderFilled,
|
||||||
|
chartRealtimeSource,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
|
||||||
|
const [selectedStrategyId, setSelectedStrategyId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (defaultMarket) setSelectedMarket(defaultMarket);
|
||||||
|
}, [defaultMarket]);
|
||||||
|
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
|
||||||
|
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
||||||
|
|
||||||
|
const tradePrice = useMemo(() => {
|
||||||
|
const t = tickers.get(selectedMarket);
|
||||||
|
return t?.tradePrice ?? null;
|
||||||
|
}, [tickers, selectedMarket]);
|
||||||
|
|
||||||
|
const value = useMemo<WidgetDashboardContextValue>(() => ({
|
||||||
|
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 (
|
||||||
|
<WidgetDashboardContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</WidgetDashboardContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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]);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="wd-widget-host wd-widget-host--panel">
|
||||||
|
<WidgetPanelHeader title="계좌 잔고" />
|
||||||
|
<div className="wd-panel-body">
|
||||||
|
{!summary && <p className="wd-widget-empty">계좌 정보를 불러오는 중…</p>}
|
||||||
|
{summary && (
|
||||||
|
<dl className="wd-balance-grid">
|
||||||
|
{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 (
|
||||||
|
<div key={row.label} className={row.highlight ? 'wd-balance-row wd-balance-row--total' : 'wd-balance-row'}>
|
||||||
|
<dt>{row.label}</dt>
|
||||||
|
<dd className={cls}>{val}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccountBalanceWidgetHost;
|
||||||
@@ -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<BacktestResultRecord[]>([]);
|
||||||
|
const [liveItems, setLiveItems] = useState<ReturnType<typeof buildLiveExecutionItems>>([]);
|
||||||
|
const [selectedBacktestId, setSelectedBacktestId] = useState<number | null>(null);
|
||||||
|
const [selectedLiveId, setSelectedLiveId] = useState<string | null>(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 (
|
||||||
|
<div className="wd-widget-host wd-widget-host--backtest-list">
|
||||||
|
<BacktestExecutionList
|
||||||
|
tab={tab}
|
||||||
|
onTabChange={setTab}
|
||||||
|
backtestRecords={records}
|
||||||
|
liveItems={liveItems}
|
||||||
|
selectedBacktestId={selectedBacktestId}
|
||||||
|
selectedLiveId={selectedLiveId}
|
||||||
|
onSelectBacktest={r => setSelectedBacktestId(r.id)}
|
||||||
|
onSelectLive={item => setSelectedLiveId(item.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BacktestListWidgetHost;
|
||||||
@@ -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 (
|
||||||
|
<div className="wd-widget-host wd-widget-host--trade">
|
||||||
|
<TradeSplitOrderPanel
|
||||||
|
market={selectedMarket}
|
||||||
|
tradePrice={tradePrice}
|
||||||
|
availableKrw={availableKrw}
|
||||||
|
availableCoinQty={holding?.quantity ?? 0}
|
||||||
|
fillBuy={fillBuy}
|
||||||
|
fillSell={fillSell}
|
||||||
|
onMarketSelect={setSelectedMarket}
|
||||||
|
paperTradingEnabled={paperTradingEnabled}
|
||||||
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||||
|
onPaperOrderFilled={() => {
|
||||||
|
refreshPaperData();
|
||||||
|
onPaperOrderFilled?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BuySellWidgetHost;
|
||||||
@@ -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<string, unknown> }> = ({ config }) => {
|
||||||
|
const {
|
||||||
|
theme, selectedMarket, setSelectedMarket, strategies, selectedStrategyId, chartRealtimeSource,
|
||||||
|
} = useWidgetDashboard();
|
||||||
|
const [strategy, setStrategy] = useState<StrategyDto | undefined>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="wd-widget-host wd-widget-host--chart">
|
||||||
|
<WidgetCandleChartPanel
|
||||||
|
market={selectedMarket}
|
||||||
|
onMarketChange={setSelectedMarket}
|
||||||
|
strategy={strategy}
|
||||||
|
theme={theme}
|
||||||
|
chartRealtimeSource={chartRealtimeSource}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CandleChartWidgetHost;
|
||||||
@@ -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 (
|
||||||
|
<div className="wd-widget-host wd-widget-host--panel">
|
||||||
|
<WidgetPanelHeader
|
||||||
|
title="호가 깊이"
|
||||||
|
right={spread.bestAsk != null && spread.bestBid != null ? (
|
||||||
|
<span className="wd-panel-meta">
|
||||||
|
스프레드 {spread.spread?.toLocaleString('ko-KR') ?? '-'}
|
||||||
|
</span>
|
||||||
|
) : undefined}
|
||||||
|
/>
|
||||||
|
<div className="wd-panel-subline">{resolveName(selectedMarket, ticker)}</div>
|
||||||
|
<div className="wd-panel-body wd-depth-body">
|
||||||
|
<div className="wd-depth-side wd-depth-side--ask">
|
||||||
|
{askBars.map(u => (
|
||||||
|
<div key={`a-${u.price}`} className="wd-depth-row">
|
||||||
|
<span className="wd-depth-price wd-down">{u.price.toLocaleString('ko-KR')}</span>
|
||||||
|
<div className="wd-depth-bar-wrap">
|
||||||
|
<div
|
||||||
|
className="wd-depth-bar wd-depth-bar--ask"
|
||||||
|
style={{ width: `${(u.size / maxSize) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="wd-depth-size">{u.size.toFixed(4)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="wd-depth-mid">
|
||||||
|
{ticker?.tradePrice?.toLocaleString('ko-KR') ?? '-'}
|
||||||
|
</div>
|
||||||
|
<div className="wd-depth-side wd-depth-side--bid">
|
||||||
|
{bidBars.map(u => (
|
||||||
|
<div key={`b-${u.price}`} className="wd-depth-row">
|
||||||
|
<span className="wd-depth-price wd-up">{u.price.toLocaleString('ko-KR')}</span>
|
||||||
|
<div className="wd-depth-bar-wrap">
|
||||||
|
<div
|
||||||
|
className="wd-depth-bar wd-depth-bar--bid"
|
||||||
|
style={{ width: `${(u.size / maxSize) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="wd-depth-size">{u.size.toFixed(4)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DepthChartWidgetHost;
|
||||||
@@ -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 (
|
||||||
|
<div className="wd-widget-host wd-widget-host--panel">
|
||||||
|
<WidgetPanelHeader title="등락률 히트맵" />
|
||||||
|
<div className="wd-panel-body">
|
||||||
|
{cells.length === 0 && <p className="wd-widget-empty">히트맵 데이터 수집 중…</p>}
|
||||||
|
<div className="wd-heatmap-grid">
|
||||||
|
{cells.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.market}
|
||||||
|
type="button"
|
||||||
|
className={`wd-heatmap-cell${t.market === selectedMarket ? ' wd-heatmap-cell--on' : ''}`}
|
||||||
|
style={{ background: heatmapColor(t.changeRate) }}
|
||||||
|
title={`${resolveName(t.market, t)} ${fmtPct(t.changeRate)}`}
|
||||||
|
onClick={() => setSelectedMarket(t.market)}
|
||||||
|
>
|
||||||
|
<span className="wd-heatmap-sym">{t.market.replace('KRW-', '')}</span>
|
||||||
|
<span className="wd-heatmap-pct">{fmtPct(t.changeRate)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MarketHeatmapWidgetHost;
|
||||||
@@ -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 (
|
||||||
|
<div className="wd-widget-host wd-widget-host--market">
|
||||||
|
<MarketPanel
|
||||||
|
open
|
||||||
|
tickers={tickers}
|
||||||
|
marketInfos={marketInfos}
|
||||||
|
loading={marketLoading}
|
||||||
|
usdRate={usdRate}
|
||||||
|
activeSymbol={selectedMarket}
|
||||||
|
onSymbolSelect={setSelectedMarket}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MarketListWidgetHost;
|
||||||
@@ -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<MoverSortKey>('gainers');
|
||||||
|
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const list = tickerListFromMap(tickers);
|
||||||
|
return sortTickers(list, tab, 20);
|
||||||
|
}, [tickers, tab]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wd-widget-host wd-widget-host--panel">
|
||||||
|
<WidgetPanelHeader tabs={TABS} activeTab={tab} onTabChange={id => setTab(id as MoverSortKey)} />
|
||||||
|
<div className="wd-panel-body">
|
||||||
|
{marketLoading && items.length === 0 && (
|
||||||
|
<p className="wd-widget-empty">시세 로딩 중…</p>
|
||||||
|
)}
|
||||||
|
<WidgetSymbolList
|
||||||
|
items={items}
|
||||||
|
activeMarket={selectedMarket}
|
||||||
|
onSelect={setSelectedMarket}
|
||||||
|
showVolume={tab === 'volume'}
|
||||||
|
rank
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MarketMoversWidgetHost;
|
||||||
@@ -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 (
|
||||||
|
<div className="wd-widget-host wd-widget-host--panel">
|
||||||
|
<WidgetPanelHeader title="시장 요약" />
|
||||||
|
<div className="wd-panel-body">
|
||||||
|
<div className="wd-summary-stats">
|
||||||
|
<div className="wd-summary-stat wd-up">
|
||||||
|
<span className="wd-summary-stat-label">상승</span>
|
||||||
|
<span className="wd-summary-stat-val">{stats.rising}</span>
|
||||||
|
</div>
|
||||||
|
<div className="wd-summary-stat wd-down">
|
||||||
|
<span className="wd-summary-stat-label">하락</span>
|
||||||
|
<span className="wd-summary-stat-val">{stats.falling}</span>
|
||||||
|
</div>
|
||||||
|
<div className="wd-summary-stat wd-muted">
|
||||||
|
<span className="wd-summary-stat-label">보합</span>
|
||||||
|
<span className="wd-summary-stat-val">{stats.flat}</span>
|
||||||
|
</div>
|
||||||
|
<div className="wd-summary-stat">
|
||||||
|
<span className="wd-summary-stat-label">24h 거래대금</span>
|
||||||
|
<span className="wd-summary-stat-val">{fmtKrwShort(stats.totalVol)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className="wd-index-list">
|
||||||
|
{indices.map(t => {
|
||||||
|
if (!t) return null;
|
||||||
|
const cls = changeColorClass(t.changeRate);
|
||||||
|
return (
|
||||||
|
<li key={t.market}>
|
||||||
|
<button type="button" className="wd-index-row" onClick={() => setSelectedMarket(t.market)}>
|
||||||
|
<span className="wd-index-name">{t.koreanName || t.market.replace('KRW-', '')}</span>
|
||||||
|
<span className={`wd-index-price ${cls}`}>{t.tradePrice?.toLocaleString('ko-KR') ?? '-'}</span>
|
||||||
|
<span className={`wd-index-chg ${cls}`}>{fmtPct(t.changeRate)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MarketSummaryWidgetHost;
|
||||||
@@ -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 (
|
||||||
|
<div className="wd-widget-host wd-widget-host--panel wd-widget-host--mini-chart">
|
||||||
|
<WidgetPanelHeader title="미니 차트" />
|
||||||
|
<div className="wd-panel-subline">{resolveName(selectedMarket, ticker)} · {selectedMarket}</div>
|
||||||
|
<div className="wd-mini-chart-wrap">
|
||||||
|
<PaperMiniChart market={selectedMarket} theme={theme} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MiniChartWidgetHost;
|
||||||
@@ -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<string | null>(null);
|
||||||
|
|
||||||
|
if (allNotifications.length === 0) {
|
||||||
|
return <p className="wd-widget-empty">알림이 없습니다.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wd-widget-host wd-widget-host--notifications">
|
||||||
|
<VirtualScroll
|
||||||
|
className="wd-notification-scroll vl-scroll"
|
||||||
|
items={allNotifications}
|
||||||
|
estimateSize={120}
|
||||||
|
measureDynamic
|
||||||
|
overscan={3}
|
||||||
|
getItemKey={i => i.id}
|
||||||
|
innerClassName="vl-scroll-inner"
|
||||||
|
aria-label="알림 목록"
|
||||||
|
>
|
||||||
|
{item => (
|
||||||
|
<TradeNotificationListRow
|
||||||
|
item={item}
|
||||||
|
theme={theme}
|
||||||
|
isSelected={selectedId === item.id}
|
||||||
|
layoutMode="list"
|
||||||
|
chartsEnabled={false}
|
||||||
|
ticker={tickers.get(item.market)}
|
||||||
|
onSelect={() => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</VirtualScroll>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationListWidgetHost;
|
||||||
@@ -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 (
|
||||||
|
<div className="wd-widget-host wd-widget-host--orderbook">
|
||||||
|
<TradeOrderbookPanelContent
|
||||||
|
market={selectedMarket}
|
||||||
|
asks={orderbook.asks}
|
||||||
|
bids={orderbook.bids}
|
||||||
|
totalAskSize={orderbook.totalAskSize}
|
||||||
|
totalBidSize={orderbook.totalBidSize}
|
||||||
|
wsStatus={obWsStatus}
|
||||||
|
bestAsk={spread.bestAsk}
|
||||||
|
bestBid={spread.bestBid}
|
||||||
|
spread={spread.spread}
|
||||||
|
spreadPct={spread.pct}
|
||||||
|
prevClose={orderbookPrevClose}
|
||||||
|
tickerInfo={orderbookTickerInfo}
|
||||||
|
recentTrades={recentTrades}
|
||||||
|
tradeStrength={tradeStrength ?? undefined}
|
||||||
|
onRowClick={onRowClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OrderbookWidgetHost;
|
||||||
@@ -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 (
|
||||||
|
<div className="wd-widget-host wd-widget-host--kpi">
|
||||||
|
<PaperInvestmentKpi summary={summary} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PaperKpiWidgetHost;
|
||||||
@@ -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<PaperOrderDto[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className="wd-widget-host wd-widget-host--panel">
|
||||||
|
<WidgetPanelHeader title="미체결 주문" right={<span className="wd-panel-count">{orders.length}건</span>} />
|
||||||
|
<div className="wd-panel-body">
|
||||||
|
{loading && orders.length === 0 && <p className="wd-widget-empty">주문 조회 중…</p>}
|
||||||
|
{!loading && orders.length === 0 && <p className="wd-widget-empty">미체결 주문이 없습니다.</p>}
|
||||||
|
<ul className="wd-orders-list">
|
||||||
|
{orders.map(o => {
|
||||||
|
const ticker = tickers.get(o.symbol);
|
||||||
|
const isBuy = o.side === 'BUY';
|
||||||
|
return (
|
||||||
|
<li key={o.id} className="wd-order-item">
|
||||||
|
<button type="button" className="wd-order-main" onClick={() => setSelectedMarket(o.symbol)}>
|
||||||
|
<span className={`wd-order-side ${isBuy ? 'wd-up' : 'wd-down'}`}>{isBuy ? '매수' : '매도'}</span>
|
||||||
|
<span className="wd-order-sym">{resolveName(o.symbol, ticker)}</span>
|
||||||
|
<span className="wd-order-price">{(o.limitPrice ?? 0).toLocaleString('ko-KR')}</span>
|
||||||
|
<span className="wd-order-qty">{o.quantity}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="wd-order-cancel" onClick={() => void handleCancel(o.id)}>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PendingOrdersWidgetHost;
|
||||||
@@ -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 (
|
||||||
|
<div className="wd-widget-host wd-widget-host--panel">
|
||||||
|
<WidgetPanelHeader
|
||||||
|
title="보유 종목"
|
||||||
|
right={summary ? (
|
||||||
|
<span className="wd-panel-meta">
|
||||||
|
평가 {summary.stockEvalAmount.toLocaleString('ko-KR')}원
|
||||||
|
</span>
|
||||||
|
) : undefined}
|
||||||
|
/>
|
||||||
|
<div className="wd-panel-body">
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<p className="wd-widget-empty">보유 중인 종목이 없습니다.</p>
|
||||||
|
)}
|
||||||
|
<ul className="wd-positions-list">
|
||||||
|
{rows.map(row => {
|
||||||
|
const cls = changeColorClass(row.pnlPct / 100);
|
||||||
|
const ticker = tickers.get(row.symbol);
|
||||||
|
return (
|
||||||
|
<li key={row.symbol}>
|
||||||
|
<button type="button" className="wd-position-row" onClick={() => setSelectedMarket(row.symbol)}>
|
||||||
|
<div className="wd-position-top">
|
||||||
|
<span className="wd-position-name">{resolveName(row.symbol, ticker)}</span>
|
||||||
|
<span className={`wd-position-pnl ${cls}`}>
|
||||||
|
{row.pnl >= 0 ? '+' : ''}{row.pnl.toLocaleString('ko-KR')}
|
||||||
|
{' '}({row.pnlPct >= 0 ? '+' : ''}{row.pnlPct.toFixed(2)}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="wd-position-bottom">
|
||||||
|
<span>{row.quantity.toFixed(4)} @ {row.avgPrice.toLocaleString('ko-KR')}</span>
|
||||||
|
<span>평가 {row.evalAmt.toLocaleString('ko-KR')}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PositionsWidgetHost;
|
||||||
@@ -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 (
|
||||||
|
<div className="wd-widget-host wd-widget-host--panel">
|
||||||
|
<div className="wd-panel-header">
|
||||||
|
<span className="wd-panel-title">{resolveName(selectedMarket, ticker)}</span>
|
||||||
|
<span className="wd-panel-sub">{selectedMarket}</span>
|
||||||
|
</div>
|
||||||
|
<div className="wd-panel-body wd-quote-body">
|
||||||
|
<VirtualTargetQuote market={selectedMarket} ticker={ticker} />
|
||||||
|
<dl className="wd-quote-grid">
|
||||||
|
<div>
|
||||||
|
<dt>시가</dt>
|
||||||
|
<dd>{ticker?.openingPrice?.toLocaleString('ko-KR') ?? '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>고가</dt>
|
||||||
|
<dd className="wd-up">{ticker?.highPrice?.toLocaleString('ko-KR') ?? '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>저가</dt>
|
||||||
|
<dd className="wd-down">{ticker?.lowPrice?.toLocaleString('ko-KR') ?? '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>전일대비</dt>
|
||||||
|
<dd className={cls}>
|
||||||
|
{ticker?.changePrice != null
|
||||||
|
? `${ticker.changePrice >= 0 ? '+' : ''}${ticker.changePrice.toLocaleString('ko-KR')} (${fmtPct(ticker.changeRate)})`
|
||||||
|
: '-'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>24h 거래대금</dt>
|
||||||
|
<dd>{fmtKrwShort(ticker?.accTradePrice24)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>24h 거래량</dt>
|
||||||
|
<dd>{ticker?.accTradeVolume24?.toLocaleString('ko-KR', { maximumFractionDigits: 2 }) ?? '-'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<button type="button" className="wd-quote-link-btn" onClick={() => setSelectedMarket(selectedMarket)}>
|
||||||
|
차트·호가 연동 종목
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuoteWidgetHost;
|
||||||
@@ -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<string, unknown> }> = ({ 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 <p className="wd-widget-empty">전략을 선택하세요.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wd-widget-host wd-widget-host--chart">
|
||||||
|
<PaperAnalysisChart
|
||||||
|
market={selectedMarket}
|
||||||
|
strategyId={strategyId}
|
||||||
|
theme={theme}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StrategyChartWidgetHost;
|
||||||
@@ -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 <p className="wd-widget-empty">등록된 전략이 없습니다.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wd-widget-host wd-widget-host--strategy-list">
|
||||||
|
<VirtualScroll
|
||||||
|
className="wd-strategy-scroll vl-scroll"
|
||||||
|
items={strategies}
|
||||||
|
estimateSize={44}
|
||||||
|
overscan={5}
|
||||||
|
getItemKey={s => String(s.id)}
|
||||||
|
innerClassName="vl-scroll-inner wd-strategy-list"
|
||||||
|
aria-label="전략 목록"
|
||||||
|
>
|
||||||
|
{s => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`wd-strategy-item${selectedStrategyId === s.id ? ' wd-strategy-item--on' : ''}`}
|
||||||
|
onClick={() => setSelectedStrategyId(s.id ?? null)}
|
||||||
|
>
|
||||||
|
<span className="wd-strategy-item-name">{s.name}</span>
|
||||||
|
{s.description && (
|
||||||
|
<span className="wd-strategy-item-desc">{s.description}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</VirtualScroll>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StrategyListWidgetHost;
|
||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div className="wd-widget-host wd-widget-host--tape">
|
||||||
|
<p className="wd-widget-empty">티커 데이터 수집 중…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="wd-widget-host wd-widget-host--tape"
|
||||||
|
onMouseEnter={() => setPaused(true)}
|
||||||
|
onMouseLeave={() => setPaused(false)}
|
||||||
|
>
|
||||||
|
<div className="wd-tape-viewport">
|
||||||
|
<div ref={trackRef} className="wd-tape-track">
|
||||||
|
{tapeItems.map((t, i) => {
|
||||||
|
const cls = changeColorClass(t.changeRate);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${t.market}-${i}`}
|
||||||
|
type="button"
|
||||||
|
className="wd-tape-item"
|
||||||
|
onClick={() => setSelectedMarket(t.market)}
|
||||||
|
>
|
||||||
|
<span className="wd-tape-name">{resolveName(t.market, t)}</span>
|
||||||
|
<span className="wd-tape-price">{t.tradePrice?.toLocaleString('ko-KR') ?? '-'}</span>
|
||||||
|
<span className={`wd-tape-chg ${cls}`}>{fmtPct(t.changeRate)}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TickerTapeWidgetHost;
|
||||||
@@ -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 (
|
||||||
|
<div className="wd-widget-host wd-widget-host--panel">
|
||||||
|
<WidgetPanelHeader
|
||||||
|
title="체결"
|
||||||
|
right={strength != null ? (
|
||||||
|
<span className="wd-panel-meta">체결강도 {strength.toFixed(0)}%</span>
|
||||||
|
) : undefined}
|
||||||
|
/>
|
||||||
|
<div className="wd-panel-subline">{resolveName(selectedMarket, ticker)} · {selectedMarket}</div>
|
||||||
|
<div className="wd-panel-body wd-timesales-body">
|
||||||
|
<table className="wd-timesales-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>시간</th>
|
||||||
|
<th>체결가</th>
|
||||||
|
<th>체결량</th>
|
||||||
|
<th>구분</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{trades.length === 0 && (
|
||||||
|
<tr><td colSpan={4} className="wd-widget-empty">체결 데이터 수집 중…</td></tr>
|
||||||
|
)}
|
||||||
|
{trades.map((t, i) => (
|
||||||
|
<tr key={`${t.time}-${i}`} className={t.side === 'bid' ? 'wd-ts-buy' : 'wd-ts-sell'}>
|
||||||
|
<td>{fmtTime(t.time)}</td>
|
||||||
|
<td>{t.price.toLocaleString('ko-KR')}</td>
|
||||||
|
<td>{t.volume >= 1 ? t.volume.toFixed(4) : t.volume.toFixed(6)}</td>
|
||||||
|
<td>{t.side === 'bid' ? '매수' : '매도'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TimeSalesWidgetHost;
|
||||||
@@ -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 (
|
||||||
|
<div className="wd-widget-host wd-widget-host--history">
|
||||||
|
<PaperTradeHistoryList
|
||||||
|
trades={trades}
|
||||||
|
strategyNames={strategyNames}
|
||||||
|
tickers={tickers}
|
||||||
|
onSelectMarket={setSelectedMarket}
|
||||||
|
className="ptd-trade-history--fill"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TradeHistoryWidgetHost;
|
||||||
@@ -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<string[]>(() => getFavorites());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = (e: Event) => {
|
||||||
|
setFavorites((e as CustomEvent<string[]>).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 (
|
||||||
|
<div className="wd-widget-host wd-widget-host--panel">
|
||||||
|
<WidgetPanelHeader title="관심종목" right={<span className="wd-panel-count">{favorites.length}종목</span>} />
|
||||||
|
<div className="wd-panel-body">
|
||||||
|
{favorites.length === 0 && (
|
||||||
|
<p className="wd-widget-empty">관심종목이 없습니다. 종목 목록에서 ★를 눌러 추가하세요.</p>
|
||||||
|
)}
|
||||||
|
{favorites.length > 0 && (
|
||||||
|
<ul className="wd-symbol-list">
|
||||||
|
{favorites.map(market => {
|
||||||
|
const t = tickers.get(market);
|
||||||
|
if (!t) {
|
||||||
|
return (
|
||||||
|
<li key={market}>
|
||||||
|
<button type="button" className="wd-symbol-row" onClick={() => setSelectedMarket(market)}>
|
||||||
|
<span className="wd-symbol-name">{market}</span>
|
||||||
|
<span className="wd-widget-empty-inline">시세 대기</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rate = t.changeRate;
|
||||||
|
const cls = rate != null && rate > 0 ? 'wd-up' : rate != null && rate < 0 ? 'wd-down' : 'wd-muted';
|
||||||
|
return (
|
||||||
|
<li key={market}>
|
||||||
|
<div
|
||||||
|
className={`wd-symbol-row${market === selectedMarket ? ' wd-symbol-row--on' : ''}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="wd-watch-star"
|
||||||
|
title="관심 해제"
|
||||||
|
onClick={e => void handleToggle(market, e)}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="wd-symbol-row-main"
|
||||||
|
onClick={() => setSelectedMarket(market)}
|
||||||
|
>
|
||||||
|
<span className="wd-symbol-name">{t.koreanName || market.replace('KRW-', '')}</span>
|
||||||
|
<span className={`wd-symbol-price ${cls}`}>{t.tradePrice?.toLocaleString('ko-KR') ?? '-'}</span>
|
||||||
|
<span className={`wd-symbol-chg ${cls}`}>
|
||||||
|
{rate != null ? `${(rate * 100).toFixed(2)}%` : '-'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WatchlistWidgetHost;
|
||||||
@@ -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<Props> = ({
|
||||||
|
title,
|
||||||
|
tabs,
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
right,
|
||||||
|
}) => (
|
||||||
|
<div className="wd-panel-header">
|
||||||
|
{title && <span className="wd-panel-title">{title}</span>}
|
||||||
|
{tabs && tabs.length > 0 && (
|
||||||
|
<div className="wd-panel-tabs" role="tablist">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab.id}
|
||||||
|
className={`wd-panel-tab${activeTab === tab.id ? ' wd-panel-tab--on' : ''}`}
|
||||||
|
onClick={() => onTabChange?.(tab.id)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{right && <div className="wd-panel-header-right">{right}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default WidgetPanelHeader;
|
||||||
@@ -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<Props> = ({
|
||||||
|
items,
|
||||||
|
activeMarket,
|
||||||
|
onSelect,
|
||||||
|
showVolume = false,
|
||||||
|
rank = false,
|
||||||
|
}) => {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return <p className="wd-widget-empty">표시할 종목이 없습니다.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="wd-symbol-list">
|
||||||
|
{items.map((t, i) => {
|
||||||
|
const rate = t.changeRate;
|
||||||
|
const cls = changeColorClass(rate);
|
||||||
|
return (
|
||||||
|
<li key={t.market}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`wd-symbol-row${t.market === activeMarket ? ' wd-symbol-row--on' : ''}`}
|
||||||
|
onClick={() => onSelect?.(t.market)}
|
||||||
|
>
|
||||||
|
{rank && <span className="wd-symbol-rank">{i + 1}</span>}
|
||||||
|
<span className="wd-symbol-name">{resolveName(t.market, t)}</span>
|
||||||
|
<span className="wd-symbol-code">{t.market.replace('KRW-', '')}</span>
|
||||||
|
<span className={`wd-symbol-price ${cls}`}>
|
||||||
|
{t.tradePrice?.toLocaleString('ko-KR') ?? '-'}
|
||||||
|
</span>
|
||||||
|
<span className={`wd-symbol-chg ${cls}`}>{fmtPct(rate)}</span>
|
||||||
|
{showVolume && (
|
||||||
|
<span className="wd-symbol-vol">{fmtKrwShort(t.accTradePrice24)}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WidgetSymbolList;
|
||||||
@@ -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<string, TickerData>): 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})`;
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
Host: React.FC<{ config?: Record<string, unknown> }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WIDGET_GROUP_LABELS: Record<WidgetGroupId, string> = {
|
||||||
|
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 ?? '위젯 선택';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user