diff --git a/frontend/src/App.css b/frontend/src/App.css index 896623b..989c817 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1103,6 +1103,34 @@ html.theme-blue { animation: flyout-in-right 0.1s ease; } +/* Custom / Custom1 — body 포털, 차트 레이어 위·항목 색상 통일 */ +.tv-side-flyout--custom-overlay { + z-index: 10050; + background: var(--bg2); + isolation: isolate; +} +.tv-side-flyout--custom-overlay .tv-side-flyout-item { + color: var(--text2); + background: transparent; +} +.tv-side-flyout--custom-overlay .tv-side-flyout-item:hover, +.tv-side-flyout--custom-overlay .tv-side-flyout-item:focus-visible { + background: var(--bg3); + color: var(--text); +} +.tv-side-flyout--custom-overlay .tv-side-flyout-item.active { + background: rgba(122, 162, 247, 0.15); + color: var(--accent); +} +.tv-side-flyout--custom-overlay .tv-side-flyout-item.active:hover { + background: rgba(122, 162, 247, 0.22); + color: var(--accent); +} +.tv-side-flyout--custom-overlay .tv-side-flyout-icon, +.tv-side-flyout--custom-overlay .tv-side-flyout-title { + color: inherit; +} + @keyframes flyout-in-right { from { opacity: 0; transform: translateX(calc(-100% + 6px)); } to { opacity: 1; transform: translateX(-100%); } @@ -2561,6 +2589,74 @@ html.theme-blue { border-color: rgba(122, 162, 247, 0.65); background: rgba(122, 162, 247, 0.22); } +.chart-right-toolbar-btn--custom1 { + position: relative; +} +.chart-right-toolbar-custom1-badge { + position: absolute; + right: 2px; + bottom: 2px; + font-size: 8px; + font-weight: 700; + line-height: 1; + color: var(--text-muted, #787b86); + pointer-events: none; +} +.chart-right-toolbar-btn--custom1.active .chart-right-toolbar-custom1-badge { + color: var(--accent, #2962ff); +} +.chart-right-toolbar-btn--custom.active, +.chart-right-toolbar-btn--custom1.active { + color: var(--accent); + border-color: rgba(122, 162, 247, 0.65); + background: rgba(122, 162, 247, 0.22); +} + +/* Custom 오버레이 설정 모달 (보조지표 설정 ibsm 스타일) */ +.ccos-dialog { + max-height: 88vh; +} +.ccos-hint { + margin: 0; + padding: 8px 14px 0; +} +.ccos-body { + padding-top: 8px; +} +.ccos-item-grid { + display: grid; + gap: 8px 12px; + align-items: center; +} +.ccos-item-grid--2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +.ccos-item-grid--3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} +.ccos-grid-item { + display: flex; + align-items: center; + gap: 8px; + min-height: 28px; + padding: 4px 6px; + border-radius: 4px; + cursor: pointer; + user-select: none; +} +.ccos-grid-item:hover { + background: rgba(255, 255, 255, 0.04); +} +.ccos-grid-toggle { + flex-shrink: 0; +} +.ccos-grid-label { + font-size: 12px; + color: var(--text); + line-height: 1.3; + flex: 1; + min-width: 0; +} /* ── PaneLegend 드래그 핸들 ─────────────────────────────────────────── */ .pane-legend-item { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 31b4b24..5f11e1f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -54,6 +54,15 @@ import { DEFAULT_CHART_OVERLAY_VISIBILITY, listChartOverlayToggleItems, } from './utils/chartOverlayVisibility'; +import { + type ChartCustomOverlaySelection, + type ChartCustomOverlaySlotId, + createDefaultChartCustomOverlaySelection, + isCustomOverlaySlotActive, + resolveActiveCustomOverlaySlot, + syncChartManagerCustomOverlaysByActiveSlot, +} from './utils/chartCustomOverlay'; +import ChartCustomOverlaySettingsModal from './components/ChartCustomOverlaySettingsModal'; import { isUpbitMarket, UPBIT_MARKETS } from './utils/upbitApi'; import { getFavorites, saveFavorites, initFavoritesFromDb, initHoldingsFromDb, FAVORITES_CHANGED_EVENT } from './utils/marketStorage'; import { useChartRealtimeData, type WsStatus } from './hooks/useChartRealtimeData'; @@ -1455,6 +1464,73 @@ function App() { [overlayVisibility], ); + const [customOverlaySelection, setCustomOverlaySelection] = useState( + () => createDefaultChartCustomOverlaySelection(), + ); + const [custom1OverlaySelection, setCustom1OverlaySelection] = useState( + () => createDefaultChartCustomOverlaySelection(), + ); + const [activeCustomOverlaySlot, setActiveCustomOverlaySlot] = useState(null); + const [customOverlaySettingsSlot, setCustomOverlaySettingsSlot] = useState(null); + const customOverlayActive = isCustomOverlaySlotActive(activeCustomOverlaySlot, 'custom'); + const custom1OverlayActive = isCustomOverlaySlotActive(activeCustomOverlaySlot, 'custom1'); + + const syncCustomOverlaysToManager = useCallback((mgr: import('./utils/ChartManager').ChartManager) => { + syncChartManagerCustomOverlaysByActiveSlot( + mgr, + activeCustomOverlaySlot, + customOverlaySelection, + custom1OverlaySelection, + ); + }, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]); + + const handleCustomOverlayActiveChange = useCallback((active: boolean) => { + const nextSlot = resolveActiveCustomOverlaySlot('custom', active, activeCustomOverlaySlot); + setActiveCustomOverlaySlot(nextSlot); + const mgr = managerRef.current; + if (mgr) { + syncChartManagerCustomOverlaysByActiveSlot( + mgr, + nextSlot, + customOverlaySelection, + custom1OverlaySelection, + ); + } + }, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]); + + const handleCustom1OverlayActiveChange = useCallback((active: boolean) => { + const nextSlot = resolveActiveCustomOverlaySlot('custom1', active, activeCustomOverlaySlot); + setActiveCustomOverlaySlot(nextSlot); + const mgr = managerRef.current; + if (mgr) { + syncChartManagerCustomOverlaysByActiveSlot( + mgr, + nextSlot, + customOverlaySelection, + custom1OverlaySelection, + ); + } + }, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]); + + const handleSaveCustomOverlaySelection = useCallback(( + slot: ChartCustomOverlaySlotId, + selection: ChartCustomOverlaySelection, + ) => { + const nextCustomSel = slot === 'custom' ? selection : customOverlaySelection; + const nextCustom1Sel = slot === 'custom1' ? selection : custom1OverlaySelection; + if (slot === 'custom') setCustomOverlaySelection(selection); + else setCustom1OverlaySelection(selection); + setCustomOverlaySettingsSlot(null); + const mgr = managerRef.current; + if (!mgr) return; + syncChartManagerCustomOverlaysByActiveSlot( + mgr, + activeCustomOverlaySlot, + nextCustomSel, + nextCustom1Sel, + ); + }, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]); + const handleToggleCandleOverlay = useCallback((key: ChartOverlayToggleKey) => { setOverlayVisibility(prev => { const next = { ...prev, [key]: !prev[key] }; @@ -2451,7 +2527,9 @@ function App() { onCrosshair={setLegend} onTradeOrderRequest={applyTradeFill} onDataLoaded={() => { - managerRef.current?.setChartOverlayVisibility(overlayVisibilityRef.current); + const mgr = managerRef.current; + mgr?.setChartOverlayVisibility(overlayVisibilityRef.current); + if (mgr) syncCustomOverlaysToManager(mgr); syncBacktestMarkersRef.current(); }} onCandlesReady={handleCandlesReady} @@ -2474,6 +2552,7 @@ function App() { mgr.setPaneSeparatorOptions(chartPaneSeparator); mgr.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat); mgr.setChartOverlayVisibility(overlayVisibilityRef.current); + syncCustomOverlaysToManager(mgr); syncBacktestMarkersRef.current(); // 왼쪽 스크롤 감지 → 과거 데이터 추가 로드 mgr.subscribeVisibleLogicalRange(r => { @@ -2520,8 +2599,30 @@ function App() { paneSeparatorOptions={chartPaneSeparator} candleOverlayToggles={candleOverlayToggles} onToggleCandleOverlay={handleToggleCandleOverlay} + customOverlayActive={customOverlayActive} + onCustomOverlayActiveChange={handleCustomOverlayActiveChange} + onOpenCustomOverlaySettings={() => setCustomOverlaySettingsSlot('custom')} + custom1OverlayActive={custom1OverlayActive} + onCustom1OverlayActiveChange={handleCustom1OverlayActiveChange} + onOpenCustom1OverlaySettings={() => setCustomOverlaySettingsSlot('custom1')} showHoverToolbar={chartHoverToolbarVisible} /> + {customOverlaySettingsSlot === 'custom' && ( + handleSaveCustomOverlaySelection('custom', sel)} + onCancel={() => setCustomOverlaySettingsSlot(null)} + /> + )} + {customOverlaySettingsSlot === 'custom1' && ( + handleSaveCustomOverlaySelection('custom1', sel)} + onCancel={() => setCustomOverlaySettingsSlot(null)} + /> + )} {isSingleLoadingMore && (
diff --git a/frontend/src/components/ChartCustomOverlaySettingsModal.tsx b/frontend/src/components/ChartCustomOverlaySettingsModal.tsx new file mode 100644 index 0000000..928226c --- /dev/null +++ b/frontend/src/components/ChartCustomOverlaySettingsModal.tsx @@ -0,0 +1,267 @@ +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import { + type ChartCustomOverlaySelection, + type CustomOverlaySectionId, + cloneChartCustomOverlaySelection, + smaMaLabel, + BB_CUSTOM_LABELS, + ICHIMOKU_CUSTOM_ROWS, + isCustomSectionAllSelected, + setCustomSectionAllSelected, +} from '../utils/chartCustomOverlay'; +import { SMA_MA_COUNT, smaPlotId } from '../utils/smaConfig'; +import { useDraggablePanel } from '../hooks/useDraggablePanel'; + +interface ChartCustomOverlaySettingsModalProps { + title?: string; + selection: ChartCustomOverlaySelection; + onSave: (selection: ChartCustomOverlaySelection) => void; + onCancel: () => void; +} + +const SECTION_META: Array<{ + id: CustomOverlaySectionId; + ko: string; + en: string; + cols: 2 | 3; +}> = [ + { id: 'sma', ko: '이동평균선', en: 'Simple Moving Average (SMA)', cols: 3 }, + { id: 'bollinger', ko: '볼린저 밴드', en: 'Bollinger Bands', cols: 2 }, + { id: 'ichimoku', ko: '일목균형표', en: 'Ichimoku Cloud', cols: 3 }, + { id: 'candle', ko: '캔들차트', en: 'Candlestick', cols: 2 }, +]; + +const ALL_SECTIONS = new Set(SECTION_META.map(s => s.id)); + +const GridToggle: React.FC<{ + label: string; + checked: boolean; + onChange: (v: boolean) => void; +}> = ({ label, checked, onChange }) => ( +
+ + {label} +
+); + +const ChartCustomOverlaySettingsModal: React.FC = ({ + title = 'Custom 표시 설정', + selection, + onSave, + onCancel, +}) => { + const draftRef = useRef(cloneChartCustomOverlaySelection(selection)); + const [, bump] = useState(0); + const refresh = useCallback(() => bump(n => n + 1), []); + const [expanded, setExpanded] = useState>( + () => new Set(ALL_SECTIONS), + ); + + const { + panelRef, + dragging, + onHeaderPointerDown, + headerTouchStyle, + panelStyle, + headerCursor, + } = useDraggablePanel({ centerOnMount: true }); + + useEffect(() => { + draftRef.current = cloneChartCustomOverlaySelection(selection); + setExpanded(new Set(ALL_SECTIONS)); + refresh(); + }, [selection, refresh]); + + const draft = draftRef.current; + + const patch = useCallback((patchFn: (d: ChartCustomOverlaySelection) => void) => { + patchFn(draftRef.current); + refresh(); + }, [refresh]); + + const toggleSectionExpand = useCallback((id: CustomOverlaySectionId) => { + setExpanded(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const toggleSectionAll = useCallback((id: CustomOverlaySectionId) => { + patch(d => { + const allOn = isCustomSectionAllSelected(d, id); + setCustomSectionAllSelected(d, id, !allOn); + }); + }, [patch]); + + const renderSectionItems = (sectionId: CustomOverlaySectionId) => { + switch (sectionId) { + case 'sma': + return Array.from({ length: SMA_MA_COUNT }, (_, i) => { + const pid = smaPlotId(i); + return ( + patch(d => { d.smaPlots[pid] = v; })} + /> + ); + }); + case 'bollinger': + return ( + <> + patch(d => { d.bollinger.basis = v; })} + /> + patch(d => { d.bollinger.upper = v; })} + /> + patch(d => { d.bollinger.lower = v; })} + /> + patch(d => { d.bollinger.background = v; })} + /> + + ); + case 'ichimoku': + return ICHIMOKU_CUSTOM_ROWS.map(row => ( + patch(d => { d.ichimoku[row.key] = v; })} + /> + )); + case 'candle': + return ( + patch(d => { d.candle = v; })} + /> + ); + default: + return null; + } + }; + + return ( +
{ if (e.target === e.currentTarget) onCancel(); }} + > +
e.stopPropagation()} + style={{ + ...panelStyle, + zIndex: 5002, + cursor: dragging ? 'grabbing' : undefined, + }} + > +
+ {title} +
+ +
+
+ +

+ Custom 적용 시 아래에서 켠 항목만 차트에 표시됩니다. 각 지표 설정의 on/off와는 별개이며, + 지표 우측 스위치는 해당 지표의 모든 항목을 한 번에 선택·해제합니다. +

+ +
+ {SECTION_META.map(meta => { + const isExpanded = expanded.has(meta.id); + const allOn = isCustomSectionAllSelected(draft, meta.id); + return ( +
+
+
+ {meta.ko} + {meta.en} +
+
+ + {allOn ? '전체 선택' : '일부 선택'} + +
+ + +
+ {isExpanded && ( +
+ {renderSectionItems(meta.id)} +
+ )} +
+ ); + })} +
+ +
+ + +
+
+
+ ); +}; + +export default ChartCustomOverlaySettingsModal; diff --git a/frontend/src/components/ChartRightToolbar.tsx b/frontend/src/components/ChartRightToolbar.tsx index d66a1ad..94287db 100644 --- a/frontend/src/components/ChartRightToolbar.tsx +++ b/frontend/src/components/ChartRightToolbar.tsx @@ -3,6 +3,7 @@ * pane 하단: 드래그 핸들 + 펼침메뉴(분리·복사·전체보기·종료) */ import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { createPortal } from 'react-dom'; import type { IndicatorConfig } from '../types'; import { ChartManager } from '../utils/ChartManager'; import { getPaneHostId } from '../utils/indicatorPaneMerge'; @@ -35,6 +36,13 @@ export interface ChartRightToolbarProps { onToggleHidden?: (id: string) => void; /** 지표명 툴바 — 설정 모달 */ onSettings?: (id: string) => void; + /** Custom / Custom1 오버레이 — 적용 여부 (동시 적용 불가, 설정은 슬롯별 분리) */ + customOverlayActive?: boolean; + onCustomOverlayActiveChange?: (active: boolean) => void; + onOpenCustomOverlaySettings?: () => void; + custom1OverlayActive?: boolean; + onCustom1OverlayActiveChange?: (active: boolean) => void; + onOpenCustom1OverlaySettings?: () => void; } const BTN_SIZE = 32; @@ -159,6 +167,24 @@ const IconEye = ({ hidden }: { hidden?: boolean }) => hidden ? ( ); +const CUSTOM_MENU_ID = '__custom_overlay__'; +const CUSTOM1_MENU_ID = '__custom1_overlay__'; + +const IconCustom = () => ( + + + + +); + +const IconCheck = () => ( + + + +); + const IconGear = () => ( @@ -166,6 +192,62 @@ const IconGear = () => ( ); +const CustomOverlayFlyoutMenu: React.FC<{ + title: string; + flyoutPos: { top: number; left: number }; + overlayActive: boolean; + onToggleActive: () => void; + onOpenSettings: () => void; + onCancelClose: () => void; + onScheduleClose: () => void; + onCloseFlyout: () => void; +}> = ({ + title, + flyoutPos, + overlayActive, + onToggleActive, + onOpenSettings, + onCancelClose, + onScheduleClose, + onCloseFlyout, +}) => createPortal( +
+
{title}
+ + +
, + document.body, +); + interface MenuItemDef { key: string; title: string; @@ -266,6 +348,12 @@ const ChartRightToolbar: React.FC = ({ manager, indicators, chartHeight, paused = false, candleOverlayToggles, onToggleCandleOverlay, + customOverlayActive = false, + onCustomOverlayActiveChange, + onOpenCustomOverlaySettings, + custom1OverlayActive = false, + onCustom1OverlayActiveChange, + onOpenCustom1OverlaySettings, onSplit, onRemove, onDuplicate, onExpand, onRestore, focusedId, onDragStart, onToggleHidden, onSettings, @@ -341,6 +429,10 @@ const ChartRightToolbar: React.FC = ({ const candlePane = paneLayouts.find(l => l.paneIndex === 0); const showOverlayIcons = !!(candleOverlayToggles?.length && candlePane && onToggleCandleOverlay); + const showCustomBtn = !!(showOverlayIcons && onCustomOverlayActiveChange && onOpenCustomOverlaySettings); + const showCustom1Btn = !!(showOverlayIcons && onCustom1OverlayActiveChange && onOpenCustom1OverlaySettings); + const customMenuOpen = openMenuId === CUSTOM_MENU_ID; + const custom1MenuOpen = openMenuId === CUSTOM1_MENU_ID; const focusedLayoutId = focusedId ? resolveFocusedPaneLayoutId(focusedId, indicators) @@ -395,6 +487,33 @@ const ChartRightToolbar: React.FC = ({ {OVERLAY_ICONS[item.key]} ))} + {showCustomBtn && ( + + )} + {showCustom1Btn && ( + + )}
)} @@ -457,7 +576,33 @@ const ChartRightToolbar: React.FC = ({ })}
- {openItem && openMenuId && (() => { + {customMenuOpen && showCustomBtn && ( + onCustomOverlayActiveChange?.(!customOverlayActive)} + onOpenSettings={() => onOpenCustomOverlaySettings?.()} + onCancelClose={cancelClose} + onScheduleClose={scheduleClose} + onCloseFlyout={closeFlyout} + /> + )} + + {custom1MenuOpen && showCustom1Btn && ( + onCustom1OverlayActiveChange?.(!custom1OverlayActive)} + onOpenSettings={() => onOpenCustom1OverlaySettings?.()} + onCancelClose={cancelClose} + onScheduleClose={scheduleClose} + onCloseFlyout={closeFlyout} + /> + )} + + {openItem && openMenuId && openMenuId !== CUSTOM_MENU_ID && openMenuId !== CUSTOM1_MENU_ID && (() => { const menuItems = buildMenuItems(openItem, indicators, paneItems, { focusedId, onSplit, onDuplicate, onExpand, onRestore, onRemove, onToggleHidden, onSettings, diff --git a/frontend/src/components/ChartSlot.tsx b/frontend/src/components/ChartSlot.tsx index c70eab0..8c13a94 100644 --- a/frontend/src/components/ChartSlot.tsx +++ b/frontend/src/components/ChartSlot.tsx @@ -51,6 +51,15 @@ import { DEFAULT_CHART_OVERLAY_VISIBILITY, listChartOverlayToggleItems, } from '../utils/chartOverlayVisibility'; +import { + type ChartCustomOverlaySelection, + type ChartCustomOverlaySlotId, + createDefaultChartCustomOverlaySelection, + isCustomOverlaySlotActive, + resolveActiveCustomOverlaySlot, + syncChartManagerCustomOverlaysByActiveSlot, +} from '../utils/chartCustomOverlay'; +import ChartCustomOverlaySettingsModal from './ChartCustomOverlaySettingsModal'; import { saveSlot } from '../utils/backendApi'; import type { OHLCVBar, ChartType, Theme, Timeframe, @@ -672,6 +681,73 @@ const ChartSlot = forwardRef(function ChartSlot [overlayVisibility], ); + const [customOverlaySelection, setCustomOverlaySelection] = useState( + () => createDefaultChartCustomOverlaySelection(), + ); + const [custom1OverlaySelection, setCustom1OverlaySelection] = useState( + () => createDefaultChartCustomOverlaySelection(), + ); + const [activeCustomOverlaySlot, setActiveCustomOverlaySlot] = useState(null); + const [customOverlaySettingsSlot, setCustomOverlaySettingsSlot] = useState(null); + const customOverlayActive = isCustomOverlaySlotActive(activeCustomOverlaySlot, 'custom'); + const custom1OverlayActive = isCustomOverlaySlotActive(activeCustomOverlaySlot, 'custom1'); + + const syncCustomOverlaysToManager = useCallback((mgr: ChartManager) => { + syncChartManagerCustomOverlaysByActiveSlot( + mgr, + activeCustomOverlaySlot, + customOverlaySelection, + custom1OverlaySelection, + ); + }, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]); + + const handleCustomOverlayActiveChange = useCallback((active: boolean) => { + const nextSlot = resolveActiveCustomOverlaySlot('custom', active, activeCustomOverlaySlot); + setActiveCustomOverlaySlot(nextSlot); + const mgr = managerRef.current; + if (mgr) { + syncChartManagerCustomOverlaysByActiveSlot( + mgr, + nextSlot, + customOverlaySelection, + custom1OverlaySelection, + ); + } + }, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]); + + const handleCustom1OverlayActiveChange = useCallback((active: boolean) => { + const nextSlot = resolveActiveCustomOverlaySlot('custom1', active, activeCustomOverlaySlot); + setActiveCustomOverlaySlot(nextSlot); + const mgr = managerRef.current; + if (mgr) { + syncChartManagerCustomOverlaysByActiveSlot( + mgr, + nextSlot, + customOverlaySelection, + custom1OverlaySelection, + ); + } + }, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]); + + const handleSaveCustomOverlaySelection = useCallback(( + slot: ChartCustomOverlaySlotId, + selection: ChartCustomOverlaySelection, + ) => { + const nextCustomSel = slot === 'custom' ? selection : customOverlaySelection; + const nextCustom1Sel = slot === 'custom1' ? selection : custom1OverlaySelection; + if (slot === 'custom') setCustomOverlaySelection(selection); + else setCustom1OverlaySelection(selection); + setCustomOverlaySettingsSlot(null); + const mgr = managerRef.current; + if (!mgr) return; + syncChartManagerCustomOverlaysByActiveSlot( + mgr, + activeCustomOverlaySlot, + nextCustomSel, + nextCustom1Sel, + ); + }, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]); + const handleToggleCandleOverlay = useCallback((key: ChartOverlayToggleKey) => { setOverlayVisibility(prev => { const next = { ...prev, [key]: !prev[key] }; @@ -937,6 +1013,7 @@ const ChartSlot = forwardRef(function ChartSlot mgr.setVolumeVisible(chartVolumeVisible); if (chartPaneSeparator) mgr.setPaneSeparatorOptions(chartPaneSeparator); mgr.setChartOverlayVisibility(overlayVisibilityRef.current); + syncCustomOverlaysToManager(mgr); // Time sync: 가시 시간 범위 변화 구독 mgr.subscribeVisibleTimeRange(r => { if (!r || isSyncingTimeRef.current) return; @@ -962,6 +1039,7 @@ const ChartSlot = forwardRef(function ChartSlot const mgr = managerRef.current; if (!mgr) return; mgr.setChartOverlayVisibility(overlayVisibilityRef.current); + syncCustomOverlaysToManager(mgr); if (skipSyncApplyRef.current) { skipSyncApplyRef.current = false; const fb = mgr.hasIchimoku() ? 28 : 0; @@ -1016,8 +1094,31 @@ const ChartSlot = forwardRef(function ChartSlot onFullView={onFullView ? () => onFullView(symbolRef.current) : undefined} candleOverlayToggles={compactMode ? undefined : candleOverlayToggles} onToggleCandleOverlay={compactMode ? undefined : handleToggleCandleOverlay} + customOverlayActive={compactMode ? undefined : customOverlayActive} + onCustomOverlayActiveChange={compactMode ? undefined : handleCustomOverlayActiveChange} + onOpenCustomOverlaySettings={compactMode ? undefined : () => setCustomOverlaySettingsSlot('custom')} + custom1OverlayActive={compactMode ? undefined : custom1OverlayActive} + onCustom1OverlayActiveChange={compactMode ? undefined : handleCustom1OverlayActiveChange} + onOpenCustom1OverlaySettings={compactMode ? undefined : () => setCustomOverlaySettingsSlot('custom1')} /> + {customOverlaySettingsSlot === 'custom' && !compactMode && ( + handleSaveCustomOverlaySelection('custom', sel)} + onCancel={() => setCustomOverlaySettingsSlot(null)} + /> + )} + {customOverlaySettingsSlot === 'custom1' && !compactMode && ( + handleSaveCustomOverlaySelection('custom1', sel)} + onCancel={() => setCustomOverlaySettingsSlot(null)} + /> + )} + {/* 컨텍스트 툴바 */} {ctxToolbar && (() => { const cfg = ctxToolbar.entryId !== '__main__' diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index e3fa559..a9f1285 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -165,6 +165,12 @@ interface TradingChartProps { /** 캔들 오버레이(이동평균·일목·볼린저) 표시 토글 — 우측 툴바 상단 */ candleOverlayToggles?: CandleOverlayToggleItem[]; onToggleCandleOverlay?: (key: import('../utils/chartOverlayVisibility').ChartOverlayToggleKey) => void; + customOverlayActive?: boolean; + onCustomOverlayActiveChange?: (active: boolean) => void; + onOpenCustomOverlaySettings?: () => void; + custom1OverlayActive?: boolean; + onCustom1OverlayActiveChange?: (active: boolean) => void; + onOpenCustom1OverlaySettings?: () => void; } const TradingChart: React.FC = ({ @@ -209,6 +215,12 @@ const TradingChart: React.FC = ({ showCandlePaneControls = true, candleOverlayToggles, onToggleCandleOverlay, + customOverlayActive, + onCustomOverlayActiveChange, + onOpenCustomOverlaySettings, + custom1OverlayActive, + onCustom1OverlayActiveChange, + onOpenCustom1OverlaySettings, }) => { const containerRef = useRef(null); const wrapperRef = useRef(null); // 스크롤 래퍼 @@ -1609,6 +1621,12 @@ const TradingChart: React.FC = ({ paused={paneLegendPaused} candleOverlayToggles={candleOverlayToggles} onToggleCandleOverlay={onToggleCandleOverlay} + customOverlayActive={customOverlayActive} + onCustomOverlayActiveChange={onCustomOverlayActiveChange} + onOpenCustomOverlaySettings={onOpenCustomOverlaySettings} + custom1OverlayActive={custom1OverlayActive} + onCustom1OverlayActiveChange={onCustom1OverlayActiveChange} + onOpenCustom1OverlaySettings={onOpenCustom1OverlaySettings} onSplit={onSplitIndicatorPane} onRemove={onRemoveIndicator} onDuplicate={onDuplicateIndicator} diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index e9d8a0c..e31d344 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -64,7 +64,15 @@ import { type ChartOverlayVisibility, DEFAULT_CHART_OVERLAY_VISIBILITY, chartOverlayKeyForType, + isMovingAverageOverlayType, } from './chartOverlayVisibility'; +import { + type ChartCustomOverlaySelection, + type ChartCustomOverlaySlotId, + createDefaultChartCustomOverlaySelection, + isCustomIchimokuCloudVisible, + isCustomPlotSelected, +} from './chartCustomOverlay'; import type { PlotDef, HLineStyle } from './indicatorRegistry'; import { mapHistogramSeriesData, histogramBarColor } from './plotColorUtils'; import { @@ -171,8 +179,13 @@ function shouldShowBbBandFill(config: IndicatorConfig): boolean { return true; } -function attachBbBandFill(entry: IndicatorEntry, config: IndicatorConfig): void { - if (!shouldShowBbBandFill(config)) { +function attachBbBandFill( + entry: IndicatorEntry, + config: IndicatorConfig, + showFill?: boolean, +): void { + const show = showFill ?? shouldShowBbBandFill(config); + if (!show) { detachBbBandFill(entry); return; } @@ -240,6 +253,36 @@ export class ChartManager { private _chartOverlayVisibility: ChartOverlayVisibility = { ...DEFAULT_CHART_OVERLAY_VISIBILITY, }; + private _customOverlaySlots: Record< + ChartCustomOverlaySlotId, + { active: boolean; selection: ChartCustomOverlaySelection } + > = { + custom: { + active: false, + selection: createDefaultChartCustomOverlaySelection(), + }, + custom1: { + active: false, + selection: createDefaultChartCustomOverlaySelection(), + }, + }; + + private _appliedCustomOverlaySlot(): ChartCustomOverlaySlotId | null { + if (this._customOverlaySlots.custom1.active) return 'custom1'; + if (this._customOverlaySlots.custom.active) return 'custom'; + return null; + } + + private _isCustomOverlayApplied(): boolean { + return this._appliedCustomOverlaySlot() !== null; + } + + private _getAppliedCustomOverlaySelection(): ChartCustomOverlaySelection { + const slot = this._appliedCustomOverlaySlot(); + return slot + ? this._customOverlaySlots[slot].selection + : createDefaultChartCustomOverlaySelection(); + } // ── 인디케이터 실시간 갱신 스로틀 ───────────────────────────────────────── private _indUpdateTimer: ReturnType | null = null; @@ -731,7 +774,10 @@ export class ChartManager { series.setData(mapHistogramSeriesData(filtered, plotDef)); seriesMeta.push({ plotId: plotDef.id, isHistogram: true, color: plotDef.color }); } else { - const isPlotVisible = !indicatorHidden && config.plotVisibility?.[plotDef.id] !== false; + const isPlotVisible = !indicatorHidden + && (this._isCustomOverlayApplied() && (config.type === 'SMA' || config.type === 'BollingerBands' || config.type === 'IchimokuCloud') + ? isCustomPlotSelected(this._getAppliedCustomOverlaySelection(), config.type, plotDef.id) + : config.plotVisibility?.[plotDef.id] !== false); const showPriceLabel = this.shouldShowSeriesPriceLabel(config, true, pane); const showSeriesTitle = pane < 2 && showPriceLabel; series = this.chart.addSeries(LineSeries, { @@ -804,7 +850,7 @@ export class ChartManager { this.indicators.set(config.id, entry); this.applyIndicatorStyle(config); if (config.type === 'BollingerBands' && !indicatorHidden) { - attachBbBandFill(entry, config); + attachBbBandFill(entry, config, this._shouldShowBbBandFillForConfig(config)); entry.bbFillPrimitive?.requestRefresh(); } } catch (e) { @@ -1790,13 +1836,21 @@ export class ChartManager { // bullishVisible/bearishVisible 가 둘 다 false 이면 DB 잘못된 저장값으로 간주하고 // 기본값(표시)으로 복원한다. 하나만 false 인 경우는 사용자 의도이므로 유지. - const bullishVis = colors.bullishVisible !== false; - const bearishVis = colors.bearishVisible !== false; - const effectiveBullish = (!bullishVis && !bearishVis) ? true : bullishVis; - const effectiveBearish = (!bullishVis && !bearishVis) ? true : bearishVis; + let bullishVis = colors.bullishVisible !== false; + let bearishVis = colors.bearishVisible !== false; + if (this._isCustomOverlayApplied()) { + const sel = this._getAppliedCustomOverlaySelection(); + bullishVis = isCustomIchimokuCloudVisible(sel, 'bullish'); + bearishVis = isCustomIchimokuCloudVisible(sel, 'bearish'); + } else { + const effectiveBullish = (!bullishVis && !bearishVis) ? true : bullishVis; + const effectiveBearish = (!bullishVis && !bearishVis) ? true : bearishVis; + bullishVis = effectiveBullish; + bearishVis = effectiveBearish; + } plugin.setColors(colors.bullishColor, colors.bearishColor); - plugin.setVisibility(effectiveBullish, effectiveBearish); + plugin.setVisibility(bullishVis, bearishVis); if (!this._isIndicatorEffectivelyVisible(config)) { plugin.setCloudData([]); @@ -2379,16 +2433,87 @@ export class ChartManager { return config.hidden !== true; } + getChartCustomOverlayActive(slot: ChartCustomOverlaySlotId = 'custom'): boolean { + return this._customOverlaySlots[slot].active; + } + + getChartCustomOverlaySelection(slot: ChartCustomOverlaySlotId = 'custom'): ChartCustomOverlaySelection { + const sel = this._customOverlaySlots[slot].selection; + return { + smaPlots: { ...sel.smaPlots }, + bollinger: { ...sel.bollinger }, + ichimoku: { ...sel.ichimoku }, + candle: sel.candle, + }; + } + + setChartCustomOverlaySelection( + selection: ChartCustomOverlaySelection, + slot: ChartCustomOverlaySlotId = 'custom', + ): void { + this._customOverlaySlots[slot].selection = { + smaPlots: { ...selection.smaPlots }, + bollinger: { ...selection.bollinger }, + ichimoku: { ...selection.ichimoku }, + candle: selection.candle, + }; + if (this._customOverlaySlots[slot].active) { + this.syncChartOverlayVisibility(); + } + } + + /** 슬롯 적용 시 다른 슬롯은 자동 미적용 (Custom ↔ Custom1 상호 배타) */ + setChartCustomOverlayActive(active: boolean, slot: ChartCustomOverlaySlotId = 'custom'): void { + if (this._customOverlaySlots[slot].active === active) return; + this._customOverlaySlots[slot].active = active; + if (active) { + const other: ChartCustomOverlaySlotId = slot === 'custom' ? 'custom1' : 'custom'; + this._customOverlaySlots[other].active = false; + } + this.syncChartOverlayVisibility(); + } + + /** custom 미적용 시 지표 설정 plotVisibility, 적용 시 custom 선택만 반영 */ + private _resolveSeriesVisible(config: IndicatorConfig, plotId: string | undefined): boolean { + if (!plotId || !this._isIndicatorEffectivelyVisible(config)) return false; + if (!this._isCustomOverlayApplied()) { + return config.plotVisibility?.[plotId] !== false; + } + if (config.type === 'SMA' || config.type === 'BollingerBands' || config.type === 'IchimokuCloud') { + return isCustomPlotSelected(this._getAppliedCustomOverlaySelection(), config.type, plotId); + } + if (isMovingAverageOverlayType(config.type)) { + return config.plotVisibility?.[plotId] !== false; + } + return config.plotVisibility?.[plotId] !== false; + } + + private _resolveCandleVisible(): boolean { + if (!this._chartOverlayVisibility.candle) return false; + if (!this._isCustomOverlayApplied()) return true; + return this._getAppliedCustomOverlaySelection().candle; + } + + private _shouldShowBbBandFillForConfig(config: IndicatorConfig): boolean { + if (!this._isIndicatorEffectivelyVisible(config)) return false; + if (!this._isCustomOverlayApplied()) { + const bg = resolveBbBandBackground(config.bandBackground); + if (!bg.visible) return false; + if (config.plotVisibility?.plot1 === false || config.plotVisibility?.plot2 === false) return false; + return true; + } + const b = this._getAppliedCustomOverlaySelection().bollinger; + return b.background && b.upper && b.lower; + } + /** 오버레이·plotVisibility 반영해 시리즈 visible 만 갱신 (플롯 정의 없어도 전체 시리즈 대상) */ private _applySeriesVisibilityToEntry(entry: IndicatorEntry): void { const config = entry.config; if (!config) return; - const indicatorVisible = this._isIndicatorEffectivelyVisible(config); entry.seriesList.forEach((series, i) => { const plotId = entry.seriesMeta[i]?.plotId; - const visible = indicatorVisible - && (plotId == null || config.plotVisibility?.[plotId] !== false); + const visible = this._resolveSeriesVisible(config, plotId); try { // eslint-disable-next-line @typescript-eslint/no-explicit-any series.applyOptions({ visible } as any); @@ -2402,7 +2527,7 @@ export class ChartManager { try { // eslint-disable-next-line @typescript-eslint/no-explicit-any (this.mainSeries as ISeriesApi).applyOptions({ - visible: this._chartOverlayVisibility.candle, + visible: this._resolveCandleVisible(), }); } catch { /* ok */ } } @@ -2416,8 +2541,8 @@ export class ChartManager { if (entry.type === 'BollingerBands') { detachBbBandFill(entry); - if (this._isIndicatorEffectivelyVisible(config)) { - attachBbBandFill(entry, config); + if (this._shouldShowBbBandFillForConfig(config)) { + attachBbBandFill(entry, config, true); entry.bbFillPrimitive?.requestRefresh(); } } @@ -2551,15 +2676,13 @@ export class ChartManager { const plotDefs = config.plots ?? def?.plots ?? []; const plotById = Object.fromEntries(plotDefs.map((p: PlotDef) => [p.id, p])); - const indicatorVisible = this._isIndicatorEffectivelyVisible(config); entry.seriesList.forEach((series, i) => { let plot: PlotDef | undefined; const pid = entry.seriesMeta[i]?.plotId; plot = (pid ? plotById[pid] : undefined) ?? plotDefs[i]; - const visible = indicatorVisible - && (pid == null || config.plotVisibility?.[pid] !== false); + const visible = this._resolveSeriesVisible(config, pid); if (!plot) { // eslint-disable-next-line @typescript-eslint/no-explicit-any series.applyOptions({ visible } as any); @@ -2645,8 +2768,8 @@ export class ChartManager { if (entry.type === 'BollingerBands') { detachBbBandFill(entry); - if (indicatorVisible) { - attachBbBandFill(entry, config); + if (this._shouldShowBbBandFillForConfig(config)) { + attachBbBandFill(entry, config, true); entry.bbFillPrimitive?.requestRefresh(); } } diff --git a/frontend/src/utils/chartCustomOverlay.ts b/frontend/src/utils/chartCustomOverlay.ts new file mode 100644 index 0000000..f6b24f2 --- /dev/null +++ b/frontend/src/utils/chartCustomOverlay.ts @@ -0,0 +1,237 @@ +import { SMA_MA_COUNT, smaPlotId } from './smaConfig'; + +/** Custom / Custom1 — 동일 기능, 설정만 분리 (세션만, DB 미저장) */ +export type ChartCustomOverlaySlotId = 'custom' | 'custom1'; + +export const CHART_CUSTOM_OVERLAY_SLOTS: ChartCustomOverlaySlotId[] = ['custom', 'custom1']; + +/** Custom 적용 시 표시할 항목 (세션만, DB 미저장) */ +export type ChartCustomOverlaySelection = { + smaPlots: Record; + bollinger: { + basis: boolean; + upper: boolean; + lower: boolean; + background: boolean; + }; + ichimoku: { + conversion: boolean; + base: boolean; + lagging: boolean; + spanA: boolean; + spanB: boolean; + bullishCloud: boolean; + bearishCloud: boolean; + }; + candle: boolean; +}; + +export const BB_CUSTOM_PLOT_IDS = ['plot0', 'plot1', 'plot2'] as const; +export const BB_CUSTOM_LABELS: Record<(typeof BB_CUSTOM_PLOT_IDS)[number], string> = { + plot0: '중앙값', + plot1: '어퍼', + plot2: '로우어', +}; + +export function createDefaultChartCustomOverlaySelection(): ChartCustomOverlaySelection { + const smaPlots: Record = {}; + for (let i = 0; i < SMA_MA_COUNT; i++) { + smaPlots[smaPlotId(i)] = true; + } + return { + smaPlots, + bollinger: { + basis: true, + upper: true, + lower: true, + background: true, + }, + ichimoku: { + conversion: true, + base: true, + lagging: true, + spanA: true, + spanB: true, + bullishCloud: true, + bearishCloud: true, + }, + candle: true, + }; +} + +export function cloneChartCustomOverlaySelection( + sel: ChartCustomOverlaySelection, +): ChartCustomOverlaySelection { + return { + smaPlots: { ...sel.smaPlots }, + bollinger: { ...sel.bollinger }, + ichimoku: { ...sel.ichimoku }, + candle: sel.candle, + }; +} + +export function smaMaLabel(plotIndex: number): string { + return `MA${plotIndex + 1}`; +} + +export const ICHIMOKU_CUSTOM_ROWS: Array<{ + key: keyof ChartCustomOverlaySelection['ichimoku']; + plotId?: string; + label: string; +}> = [ + { key: 'conversion', plotId: 'plot0', label: '전환선' }, + { key: 'base', plotId: 'plot1', label: '기준선' }, + { key: 'lagging', plotId: 'plot2', label: '후행스팬' }, + { key: 'spanA', plotId: 'plot3', label: '선행스팬1' }, + { key: 'spanB', plotId: 'plot4', label: '선행스팬2' }, + { key: 'bullishCloud', label: '상승 구름' }, + { key: 'bearishCloud', label: '하락 구름' }, +]; + +/** 지표 설정 plotVisibility 와 무관 — custom 적용 시 이 맵만 사용 */ +export function isCustomPlotSelected( + sel: ChartCustomOverlaySelection, + type: string, + plotId: string, +): boolean { + if (type === 'SMA') { + return sel.smaPlots[plotId] === true; + } + if (type === 'BollingerBands') { + if (plotId === 'plot0') return sel.bollinger.basis; + if (plotId === 'plot1') return sel.bollinger.upper; + if (plotId === 'plot2') return sel.bollinger.lower; + return false; + } + if (type === 'IchimokuCloud') { + const row = ICHIMOKU_CUSTOM_ROWS.find(r => r.plotId === plotId); + if (!row) return false; + return sel.ichimoku[row.key] === true; + } + return true; +} + +export function isCustomIchimokuCloudVisible( + sel: ChartCustomOverlaySelection, + which: 'bullish' | 'bearish', +): boolean { + return which === 'bullish' + ? sel.ichimoku.bullishCloud + : sel.ichimoku.bearishCloud; +} + +export type CustomOverlaySectionId = 'sma' | 'bollinger' | 'ichimoku' | 'candle'; + +export function isCustomSectionAllSelected( + sel: ChartCustomOverlaySelection, + section: CustomOverlaySectionId, +): boolean { + switch (section) { + case 'sma': + return Array.from({ length: SMA_MA_COUNT }, (_, i) => + sel.smaPlots[smaPlotId(i)] !== false, + ).every(Boolean); + case 'bollinger': + return sel.bollinger.basis + && sel.bollinger.upper + && sel.bollinger.lower + && sel.bollinger.background; + case 'ichimoku': + return ICHIMOKU_CUSTOM_ROWS.every(r => sel.ichimoku[r.key]); + case 'candle': + return sel.candle; + default: + return false; + } +} + +export function setCustomSectionAllSelected( + sel: ChartCustomOverlaySelection, + section: CustomOverlaySectionId, + on: boolean, +): void { + switch (section) { + case 'sma': + for (let i = 0; i < SMA_MA_COUNT; i++) { + sel.smaPlots[smaPlotId(i)] = on; + } + break; + case 'bollinger': + sel.bollinger.basis = on; + sel.bollinger.upper = on; + sel.bollinger.lower = on; + sel.bollinger.background = on; + break; + case 'ichimoku': + for (const row of ICHIMOKU_CUSTOM_ROWS) { + sel.ichimoku[row.key] = on; + } + break; + case 'candle': + sel.candle = on; + break; + default: + break; + } +} + +export type ChartCustomOverlaySlotState = { + active: boolean; + selection: ChartCustomOverlaySelection; +}; + +/** UI·Manager 공통: 한 번에 하나의 슬롯만 적용 (다른 슬롯 적용 시 기존 슬롯 자동 해제) */ +export function isCustomOverlaySlotActive( + activeSlot: ChartCustomOverlaySlotId | null, + slot: ChartCustomOverlaySlotId, +): boolean { + return activeSlot === slot; +} + +export function resolveActiveCustomOverlaySlot( + slot: ChartCustomOverlaySlotId, + turnOn: boolean, + current: ChartCustomOverlaySlotId | null, +): ChartCustomOverlaySlotId | null { + if (turnOn) return slot; + return current === slot ? null : current; +} + +type CustomOverlayManagerApi = { + setChartCustomOverlaySelection( + selection: ChartCustomOverlaySelection, + slot: ChartCustomOverlaySlotId, + ): void; + setChartCustomOverlayActive(active: boolean, slot: ChartCustomOverlaySlotId): void; +}; + +/** React → ChartManager: 두 슬롯 선택값 동기화 후 적용 중인 슬롯만 활성화 */ +export function syncChartManagerCustomOverlays( + mgr: CustomOverlayManagerApi, + custom: ChartCustomOverlaySlotState, + custom1: ChartCustomOverlaySlotState, +): void { + mgr.setChartCustomOverlaySelection(custom.selection, 'custom'); + mgr.setChartCustomOverlaySelection(custom1.selection, 'custom1'); + mgr.setChartCustomOverlayActive(false, 'custom'); + mgr.setChartCustomOverlayActive(false, 'custom1'); + if (custom1.active) { + mgr.setChartCustomOverlayActive(true, 'custom1'); + } else if (custom.active) { + mgr.setChartCustomOverlayActive(true, 'custom'); + } +} + +/** 적용 슬롯 하나만 — Custom 적용 중 Custom1 적용 시 Custom 자동 미적용 */ +export function syncChartManagerCustomOverlaysByActiveSlot( + mgr: CustomOverlayManagerApi, + activeSlot: ChartCustomOverlaySlotId | null, + customSelection: ChartCustomOverlaySelection, + custom1Selection: ChartCustomOverlaySelection, +): void { + syncChartManagerCustomOverlays( + mgr, + { active: activeSlot === 'custom', selection: customSelection }, + { active: activeSlot === 'custom1', selection: custom1Selection }, + ); +}