diff --git a/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java b/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java index 9a04167..be60374 100644 --- a/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java +++ b/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java @@ -122,6 +122,11 @@ public class GcAppSettings { @Builder.Default private Boolean chartCrosshairInfoVisible = true; + /** 차트 마우스오버 줌·이동 플로팅 툴바(− + ⌐↔ ‹ ›) 표시 (기본 true) */ + @Column(name = "chart_hover_toolbar_visible", nullable = false) + @Builder.Default + private Boolean chartHoverToolbarVisible = true; + /** 차트 상단 범례(tv-legend) 항목별 표시 옵션 JSON */ @Column(name = "chart_legend_options_json", columnDefinition = "JSON") @JdbcTypeCode(SqlTypes.JSON) diff --git a/backend/src/main/java/com/goldenchart/service/AppSettingsService.java b/backend/src/main/java/com/goldenchart/service/AppSettingsService.java index 463ee90..e70fb6d 100644 --- a/backend/src/main/java/com/goldenchart/service/AppSettingsService.java +++ b/backend/src/main/java/com/goldenchart/service/AppSettingsService.java @@ -100,6 +100,8 @@ public class AppSettingsService { Boolean.parseBoolean(d.get("chartLiveReceiveHighlight").toString())); if (d.containsKey("chartCrosshairInfoVisible")) s.setChartCrosshairInfoVisible( Boolean.parseBoolean(d.get("chartCrosshairInfoVisible").toString())); + if (d.containsKey("chartHoverToolbarVisible")) s.setChartHoverToolbarVisible( + Boolean.parseBoolean(d.get("chartHoverToolbarVisible").toString())); if (d.containsKey("chartLegendOptions")) s.setChartLegendOptionsJson(toJson(d.get("chartLegendOptions"))); if (d.containsKey("chartPaneSeparator")) s.setChartPaneSeparatorJson(toJson(d.get("chartPaneSeparator"))); if (d.containsKey("tradeAlertPopup")) s.setTradeAlertPopup( @@ -205,6 +207,7 @@ public class AppSettingsService { m.put("chartVolumeVisible", s.getChartVolumeVisible() != null ? s.getChartVolumeVisible() : true); m.put("chartLiveReceiveHighlight", s.getChartLiveReceiveHighlight() != null ? s.getChartLiveReceiveHighlight() : true); m.put("chartCrosshairInfoVisible", s.getChartCrosshairInfoVisible() != null ? s.getChartCrosshairInfoVisible() : true); + m.put("chartHoverToolbarVisible", s.getChartHoverToolbarVisible() != null ? s.getChartHoverToolbarVisible() : true); m.put("chartLegendOptions", parseJson(s.getChartLegendOptionsJson())); m.put("chartPaneSeparator", parseJson(s.getChartPaneSeparatorJson())); m.put("tradeAlertPopup", s.getTradeAlertPopup() != null ? s.getTradeAlertPopup() : true); diff --git a/backend/src/main/resources/db/migration/V57__chart_hover_toolbar_visible.sql b/backend/src/main/resources/db/migration/V57__chart_hover_toolbar_visible.sql new file mode 100644 index 0000000..7f6f8ac --- /dev/null +++ b/backend/src/main/resources/db/migration/V57__chart_hover_toolbar_visible.sql @@ -0,0 +1,3 @@ +ALTER TABLE gc_app_settings + ADD COLUMN chart_hover_toolbar_visible BOOLEAN NOT NULL DEFAULT TRUE + COMMENT '차트 마우스오버 줌·이동 플로팅 툴바 표시'; diff --git a/backend/src/main/resources/db/migration/V58__repair_chart_hover_toolbar_visible.sql b/backend/src/main/resources/db/migration/V58__repair_chart_hover_toolbar_visible.sql new file mode 100644 index 0000000..d822566 --- /dev/null +++ b/backend/src/main/resources/db/migration/V58__repair_chart_hover_toolbar_visible.sql @@ -0,0 +1,18 @@ +-- V57 이 flyway_schema_history 에만 기록되고 컬럼이 없는 DB 복구 +SET @col_exists := ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'gc_app_settings' + AND COLUMN_NAME = 'chart_hover_toolbar_visible' +); + +SET @ddl := IF( + @col_exists = 0, + 'ALTER TABLE gc_app_settings ADD COLUMN chart_hover_toolbar_visible BOOLEAN NOT NULL DEFAULT TRUE COMMENT ''차트 마우스오버 줌·이동 플로팅 툴바 표시''', + 'SELECT 1' +); + +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/frontend/src/App.css b/frontend/src/App.css index d8a64ce..896623b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2540,6 +2540,22 @@ html.theme-blue { right: 0; left: auto; } + +/* 캔들 pane — 오버레이 표시 토글 아이콘 (이평·볼린저·일목·캔들) */ +.chart-right-toolbar-cluster--overlay-icons { + z-index: 5; + align-items: center; +} +.chart-right-toolbar-btn--overlay { + opacity: 0.92; +} +.chart-right-toolbar-btn--overlay-off { + opacity: 0.38; + color: var(--text-muted, #787b86); +} +.chart-right-toolbar-btn--overlay-off:hover { + opacity: 0.65; +} .chart-right-toolbar-btn--restore.active { color: var(--accent); border-color: rgba(122, 162, 247, 0.65); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ff6e683..31b4b24 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -47,6 +47,13 @@ import { } from './utils/indicatorMainConfig'; import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from './utils/smaConfig'; import { normalizeIchimokuConfig } from './utils/ichimokuConfig'; +import { + type ChartOverlayToggleKey, + type ChartOverlayVisibility, + chartOverlayKeyForType, + DEFAULT_CHART_OVERLAY_VISIBILITY, + listChartOverlayToggleItems, +} from './utils/chartOverlayVisibility'; 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'; @@ -215,6 +222,8 @@ function App() { const [mode, setMode] = useState('chart'); const [logScale, setLogScale] = useState(false); const [indicators, setIndicators] = useState(initial.indicators ?? []); + const indicatorsRef = useRef(indicators); + useEffect(() => { indicatorsRef.current = indicators; }, [indicators]); const [drawingTool, setDrawingTool] = useState('cursor'); const [drawings, setDrawings] = useState([]); const [drawingsLocked, setDrawingsLocked] = useState(false); @@ -668,6 +677,7 @@ function App() { const chartLiveReceiveHighlight = appDefaults.chartLiveReceiveHighlight ?? true; const chartLegendOptions = appDefaults.chartLegendOptions; const chartCrosshairInfoVisible = appDefaults.chartCrosshairInfoVisible ?? true; + const chartHoverToolbarVisible = appDefaults.chartHoverToolbarVisible ?? true; const chartPaneSeparator = appDefaults.chartPaneSeparator; const paperTradingEnabled = appDefaults.paperTradingEnabled ?? true; const paperAutoTradeEnabled = appDefaults.paperAutoTradeEnabled ?? false; @@ -1432,6 +1442,37 @@ function App() { setFocusedIndicatorId(null); }, []); + const [overlayVisibility, setOverlayVisibility] = useState( + () => ({ ...DEFAULT_CHART_OVERLAY_VISIBILITY }), + ); + const overlayVisibilityRef = useRef(overlayVisibility); + useEffect(() => { + overlayVisibilityRef.current = overlayVisibility; + }, [overlayVisibility]); + + const candleOverlayToggles = useMemo( + () => listChartOverlayToggleItems(overlayVisibility), + [overlayVisibility], + ); + + 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; + }); + }, []); + /** * 보조지표 순서 변경 (드래그 핸들): fromId 를 indicators 배열에서 이동. * overlay/markers 지표는 건드리지 않고 비오버레이 지표 위치만 변경됨. @@ -2007,6 +2048,8 @@ function App() { }} chartCrosshairInfoVisible={chartCrosshairInfoVisible} onChartCrosshairInfoVisible={v => saveAppDef({ chartCrosshairInfoVisible: v })} + chartHoverToolbarVisible={chartHoverToolbarVisible} + onChartHoverToolbarVisible={v => saveAppDef({ chartHoverToolbarVisible: v })} chartPaneSeparator={chartPaneSeparator} onChartPaneSeparatorChange={opts => { saveAppDef({ chartPaneSeparator: opts }); @@ -2322,6 +2365,7 @@ function App() { chartVolumeVisible={chartVolumeVisible} chartPaneSeparator={chartPaneSeparator} chartCrosshairInfoVisible={chartCrosshairInfoVisible} + chartHoverToolbarVisible={chartHoverToolbarVisible} chartLiveReceiveHighlight={chartLiveReceiveHighlight} chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'} displayTimezone={displayTimezone} @@ -2364,6 +2408,7 @@ function App() { chartVolumeVisible={chartVolumeVisible} chartPaneSeparator={chartPaneSeparator} chartCrosshairInfoVisible={chartCrosshairInfoVisible} + chartHoverToolbarVisible={chartHoverToolbarVisible} chartLiveReceiveHighlight={chartLiveReceiveHighlight} chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'} displayTimezone={displayTimezone} @@ -2405,7 +2450,10 @@ function App() { crosshairInfoVisible={chartCrosshairInfoVisible} onCrosshair={setLegend} onTradeOrderRequest={applyTradeFill} - onDataLoaded={() => syncBacktestMarkersRef.current()} + onDataLoaded={() => { + managerRef.current?.setChartOverlayVisibility(overlayVisibilityRef.current); + syncBacktestMarkersRef.current(); + }} onCandlesReady={handleCandlesReady} onManagerReady={mgr => { managerRef.current = mgr; @@ -2425,6 +2473,7 @@ function App() { mgr.setVolumeVisible(chartVolumeVisible, wrapper?.clientHeight); mgr.setPaneSeparatorOptions(chartPaneSeparator); mgr.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat); + mgr.setChartOverlayVisibility(overlayVisibilityRef.current); syncBacktestMarkersRef.current(); // 왼쪽 스크롤 감지 → 과거 데이터 추가 로드 mgr.subscribeVisibleLogicalRange(r => { @@ -2469,6 +2518,9 @@ function App() { candleAreaPriceLabelsEnabled={chartCandleAreaPriceLabels} indicatorAreaPriceLabelsEnabled={chartSeriesPriceLabels} paneSeparatorOptions={chartPaneSeparator} + candleOverlayToggles={candleOverlayToggles} + onToggleCandleOverlay={handleToggleCandleOverlay} + showHoverToolbar={chartHoverToolbarVisible} /> {isSingleLoadingMore && (
diff --git a/frontend/src/components/ChartRightToolbar.tsx b/frontend/src/components/ChartRightToolbar.tsx index f89a2d8..d66a1ad 100644 --- a/frontend/src/components/ChartRightToolbar.tsx +++ b/frontend/src/components/ChartRightToolbar.tsx @@ -8,13 +8,10 @@ import { ChartManager } from '../utils/ChartManager'; import { getPaneHostId } from '../utils/indicatorPaneMerge'; import { isIndicatorPaneFocused, resolveFocusedPaneLayoutId } from '../utils/indicatorPaneFocus'; import { buildPaneItems, resolvePaneItemLayout } from './paneLegendLayout'; -import { PAPER_OVERLAY_MENU_TITLE } from '../utils/paperChartOverlayVisibility'; - -/** 캔들 오버레이 표시 설정 그룹 메뉴 id */ -export const CANDLE_OVERLAY_MENU_ID = '__candle_overlay_visibility__'; +import type { ChartOverlayToggleKey } from '../utils/chartOverlayVisibility'; export interface CandleOverlayToggleItem { - key: string; + key: ChartOverlayToggleKey; label: string; visible: boolean; } @@ -26,7 +23,7 @@ export interface ChartRightToolbarProps { paused?: boolean; /** 캔들 pane 상단 — 이동평균·일목·볼린저 표시/숨김 (투자관리 차트 탭) */ candleOverlayToggles?: CandleOverlayToggleItem[]; - onToggleCandleOverlay?: (key: string) => void; + onToggleCandleOverlay?: (key: ChartOverlayToggleKey) => void; onSplit?: (hostId: string) => void; onRemove?: (id: string) => void; onDuplicate?: (id: string) => void; @@ -107,6 +104,49 @@ const IconMore = () => ( ); +const IconMa = () => ( + + + +); + +const IconBollinger = () => ( + + + + + +); + +const IconCandle = () => ( + + + + + + +); + +const IconIchimoku = () => ( + + + + + + +); + +const OVERLAY_ICONS: Record = { + ma: , + bollinger: , + ichimoku: , + candle: , +}; + const IconEye = ({ hidden }: { hidden?: boolean }) => hidden ? ( @@ -295,13 +335,12 @@ const ChartRightToolbar: React.FC = ({ const allowDrag = !!(onDragStart && paneItems.length > 1); const clusterHeight = (allowDrag ? BTN_SIZE + BTN_GAP : 0) + BTN_SIZE; - const openItem = openMenuId && openMenuId !== CANDLE_OVERLAY_MENU_ID + const openItem = openMenuId ? paneItems.find(p => p.id === openMenuId) : null; const candlePane = paneLayouts.find(l => l.paneIndex === 0); - const showOverlayMenu = !!(candleOverlayToggles?.length && candlePane && onToggleCandleOverlay); - const overlayMenuOpen = openMenuId === CANDLE_OVERLAY_MENU_ID; + const showOverlayIcons = !!(candleOverlayToggles?.length && candlePane && onToggleCandleOverlay); const focusedLayoutId = focusedId ? resolveFocusedPaneLayoutId(focusedId, indicators) @@ -314,18 +353,6 @@ const ChartRightToolbar: React.FC = ({ })() : null; - const overlayMenuItems: MenuItemDef[] = showOverlayMenu - ? candleOverlayToggles!.map(item => ({ - key: item.key, - title: item.visible ? `${item.label} 숨기기` : `${item.label} 표시`, - icon: , - active: !item.visible, - onClick: () => { - onToggleCandleOverlay?.(item.key); - }, - })) - : []; - return (
= ({
)} - {showOverlayMenu && ( + {showOverlayIcons && (
- + {candleOverlayToggles!.map((item, idx) => ( + + ))}
)} @@ -425,28 +457,6 @@ const ChartRightToolbar: React.FC = ({ })}
- {overlayMenuOpen && overlayMenuItems.length > 0 && ( -
-
{PAPER_OVERLAY_MENU_TITLE}
- {overlayMenuItems.map(mi => ( - - ))} -
- )} - {openItem && openMenuId && (() => { const menuItems = buildMenuItems(openItem, indicators, paneItems, { focusedId, onSplit, onDuplicate, onExpand, onRestore, onRemove, diff --git a/frontend/src/components/ChartSlot.tsx b/frontend/src/components/ChartSlot.tsx index a5c0b14..c70eab0 100644 --- a/frontend/src/components/ChartSlot.tsx +++ b/frontend/src/components/ChartSlot.tsx @@ -44,6 +44,13 @@ import { replaceIndicatorConfigsFromTypes, } from '../utils/indicatorPaneMerge'; import { sortIndicatorsByTypeOrder } from '../utils/indicatorListOrder'; +import { + type ChartOverlayToggleKey, + type ChartOverlayVisibility, + chartOverlayKeyForType, + DEFAULT_CHART_OVERLAY_VISIBILITY, + listChartOverlayToggleItems, +} from '../utils/chartOverlayVisibility'; import { saveSlot } from '../utils/backendApi'; import type { OHLCVBar, ChartType, Theme, Timeframe, @@ -192,6 +199,8 @@ export interface ChartSlotProps { chartPaneSeparator?: ChartPaneSeparatorOptions; /** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 */ chartCrosshairInfoVisible?: boolean; + /** 차트 마우스오버 줌·이동 플로팅 툴바 */ + chartHoverToolbarVisible?: boolean; /** 업비트 실시간 티커 맵 (슬롯 심볼별 현재가·등락) */ marketTickers?: Map; } @@ -220,6 +229,7 @@ const ChartSlot = forwardRef(function ChartSlot chartVisible = true, chartPaneSeparator, chartCrosshairInfoVisible = true, + chartHoverToolbarVisible = true, marketTickers, }, ref) { // ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ──────────────────────── @@ -649,6 +659,37 @@ const ChartSlot = forwardRef(function ChartSlot setFocusedIndicatorId(null); }, []); + const [overlayVisibility, setOverlayVisibility] = useState( + () => ({ ...DEFAULT_CHART_OVERLAY_VISIBILITY }), + ); + const overlayVisibilityRef = useRef(overlayVisibility); + useEffect(() => { + overlayVisibilityRef.current = overlayVisibility; + }, [overlayVisibility]); + + const candleOverlayToggles = useMemo( + () => listChartOverlayToggleItems(overlayVisibility), + [overlayVisibility], + ); + + 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 handleDuplicateIndicator = useCallback((sourceId: string) => { setIndicators(prev => duplicateIndicatorInList(sourceId, prev, newIndId) ?? prev); }, []); @@ -869,7 +910,7 @@ const ChartSlot = forwardRef(function ChartSlot logScale={false} drawingsLocked={compactMode} drawingsVisible={!compactMode} - showHoverToolbar={!compactMode} + showHoverToolbar={!compactMode && chartHoverToolbarVisible} volumeVisible={chartVolumeVisible} candleAreaPriceLabelsEnabled={chartCandleAreaPriceLabels} indicatorAreaPriceLabelsEnabled={chartSeriesPriceLabels} @@ -895,6 +936,7 @@ const ChartSlot = forwardRef(function ChartSlot mgr.setIndicatorAreaPriceLabelsEnabled(chartSeriesPriceLabels); mgr.setVolumeVisible(chartVolumeVisible); if (chartPaneSeparator) mgr.setPaneSeparatorOptions(chartPaneSeparator); + mgr.setChartOverlayVisibility(overlayVisibilityRef.current); // Time sync: 가시 시간 범위 변화 구독 mgr.subscribeVisibleTimeRange(r => { if (!r || isSyncingTimeRef.current) return; @@ -919,6 +961,7 @@ const ChartSlot = forwardRef(function ChartSlot // 첫 마운트 시 pending 상태였던 sync range 를 재적용한다 const mgr = managerRef.current; if (!mgr) return; + mgr.setChartOverlayVisibility(overlayVisibilityRef.current); if (skipSyncApplyRef.current) { skipSyncApplyRef.current = false; const fb = mgr.hasIchimoku() ? 28 : 0; @@ -971,6 +1014,8 @@ const ChartSlot = forwardRef(function ChartSlot onToggleIndicatorHidden={handleToggleHidden} onOpenIndicatorSettings={id => setSettingsModalId(id)} onFullView={onFullView ? () => onFullView(symbolRef.current) : undefined} + candleOverlayToggles={compactMode ? undefined : candleOverlayToggles} + onToggleCandleOverlay={compactMode ? undefined : handleToggleCandleOverlay} /> {/* 컨텍스트 툴바 */} diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index 470245c..e935d1a 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -125,6 +125,8 @@ interface SettingsPageProps { onChartLegendOptionsChange?: (patch: Partial) => void; chartCrosshairInfoVisible?: boolean; onChartCrosshairInfoVisible?: (v: boolean) => void; + chartHoverToolbarVisible?: boolean; + onChartHoverToolbarVisible?: (v: boolean) => void; chartPaneSeparator?: ChartPaneSeparatorOptions; onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void; paperTradingEnabled?: boolean; @@ -783,6 +785,8 @@ interface ChartPanelProps { onChartLegendOptionsChange?: (patch: Partial) => void; chartCrosshairInfoVisible?: boolean; onChartCrosshairInfoVisible?: (v: boolean) => void; + chartHoverToolbarVisible?: boolean; + onChartHoverToolbarVisible?: (v: boolean) => void; chartPaneSeparator?: ChartPaneSeparatorOptions; onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void; chartTimeFormat?: string; @@ -807,6 +811,8 @@ const ChartPanel: React.FC = ({ onChartLegendOptionsChange, chartCrosshairInfoVisible = true, onChartCrosshairInfoVisible, + chartHoverToolbarVisible = true, + onChartHoverToolbarVisible, chartPaneSeparator, onChartPaneSeparatorChange, chartTimeFormat = 'MM-dd HH:mm', @@ -1054,6 +1060,22 @@ const ChartPanel: React.FC = ({ + + + + + + ); }; @@ -1714,6 +1736,8 @@ const SettingsPage: React.FC = ({ onChartLegendOptionsChange, chartCrosshairInfoVisible = true, onChartCrosshairInfoVisible, + chartHoverToolbarVisible = true, + onChartHoverToolbarVisible, chartPaneSeparator, onChartPaneSeparatorChange, tradeAlertSoundEnabled = true, @@ -1828,6 +1852,8 @@ const SettingsPage: React.FC = ({ onChartLegendOptionsChange={onChartLegendOptionsChange} chartCrosshairInfoVisible={chartCrosshairInfoVisible} onChartCrosshairInfoVisible={onChartCrosshairInfoVisible} + chartHoverToolbarVisible={chartHoverToolbarVisible} + onChartHoverToolbarVisible={onChartHoverToolbarVisible} chartPaneSeparator={chartPaneSeparator} onChartPaneSeparatorChange={onChartPaneSeparatorChange} chartTimeFormat={chartTimeFormat} diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index 9d31e2c..e3fa559 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -164,7 +164,7 @@ interface TradingChartProps { showCandlePaneControls?: boolean; /** 캔들 오버레이(이동평균·일목·볼린저) 표시 토글 — 우측 툴바 상단 */ candleOverlayToggles?: CandleOverlayToggleItem[]; - onToggleCandleOverlay?: (key: string) => void; + onToggleCandleOverlay?: (key: import('../utils/chartOverlayVisibility').ChartOverlayToggleKey) => void; } const TradingChart: React.FC = ({ diff --git a/frontend/src/hooks/useAppSettings.ts b/frontend/src/hooks/useAppSettings.ts index fe1736c..0d887d4 100644 --- a/frontend/src/hooks/useAppSettings.ts +++ b/frontend/src/hooks/useAppSettings.ts @@ -158,6 +158,7 @@ export function resolveAppDefaults(s: AppSettingsDto) { chartVolumeVisible: s.chartVolumeVisible ?? true, chartLiveReceiveHighlight: s.chartLiveReceiveHighlight ?? true, chartCrosshairInfoVisible: s.chartCrosshairInfoVisible ?? true, + chartHoverToolbarVisible: s.chartHoverToolbarVisible ?? true, chartLegendOptions: resolveChartLegendOptions( s.chartLegendOptions as Partial | null | undefined, ), diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index 07ee578..e9d8a0c 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -59,6 +59,12 @@ import { resolveIchimokuCloudColors, resolveIchimokuDisplacements, } from './ichimokuConfig'; +import { + type ChartOverlayToggleKey, + type ChartOverlayVisibility, + DEFAULT_CHART_OVERLAY_VISIBILITY, + chartOverlayKeyForType, +} from './chartOverlayVisibility'; import type { PlotDef, HLineStyle } from './indicatorRegistry'; import { mapHistogramSeriesData, histogramBarColor } from './plotColorUtils'; import { @@ -230,6 +236,10 @@ export class ChartManager { /** 실시간 전략 체크 마커 */ private liveStrategyMarkers: MarkerData[] = []; private compareSeriesMap = new Map>(); + /** 캔들 pane 오버레이 그룹 표시 (config.hidden 과 별도 — in-place 토글용) */ + private _chartOverlayVisibility: ChartOverlayVisibility = { + ...DEFAULT_CHART_OVERLAY_VISIBILITY, + }; // ── 인디케이터 실시간 갱신 스로틀 ───────────────────────────────────────── private _indUpdateTimer: ReturnType | null = null; @@ -668,7 +678,7 @@ export class ChartManager { config = enrichIndicatorConfig(config); if (this.indicators.has(config.id) || this.rawBars.length === 0) return; const dataGenAtStart = this._dataGeneration; - const indicatorHidden = config.hidden === true; + const indicatorHidden = !this._isIndicatorEffectivelyVisible(config); const def = getIndicatorDef(config.type); const seriesList: ISeriesApi[] = []; const seriesMeta: IndicatorEntry['seriesMeta'] = []; @@ -855,6 +865,7 @@ export class ChartManager { if (configs.length > 0) { this._finalizeIndicatorPaneLayout(); this._refreshAllIchimokuClouds(); + this.syncChartOverlayVisibility(); } } @@ -930,6 +941,7 @@ export class ChartManager { this._finalizeIndicatorPaneLayout(); this._refreshAllIchimokuClouds(); + this.syncChartOverlayVisibility(); } removeIndicator(id: string): void { @@ -1786,7 +1798,7 @@ export class ChartManager { plugin.setColors(colors.bullishColor, colors.bearishColor); plugin.setVisibility(effectiveBullish, effectiveBearish); - if (config.hidden === true) { + if (!this._isIndicatorEffectivelyVisible(config)) { plugin.setCloudData([]); return; } @@ -2343,6 +2355,89 @@ export class ChartManager { return result; } + getChartOverlayVisibility(): ChartOverlayVisibility { + return { ...this._chartOverlayVisibility }; + } + + setChartOverlayVisibility(visibility: ChartOverlayVisibility): void { + this._chartOverlayVisibility = { ...visibility }; + this.syncChartOverlayVisibility(); + } + + setChartOverlayGroupVisible(key: ChartOverlayToggleKey, visible: boolean): void { + this._chartOverlayVisibility = { + ...this._chartOverlayVisibility, + [key]: visible, + }; + this.syncChartOverlayVisibility(); + } + + /** 사용자 hidden + 세션 오버레이 토글 — entry.config.hidden 은 사용자 의도만 반영 */ + private _isIndicatorEffectivelyVisible(config: IndicatorConfig): boolean { + const overlayKey = chartOverlayKeyForType(config.type); + if (overlayKey && !this._chartOverlayVisibility[overlayKey]) return false; + return config.hidden !== true; + } + + /** 오버레이·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); + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + series.applyOptions({ visible } as any); + } catch { /* ok */ } + }); + } + + /** 오버레이 그룹·캔들 visible — 시리즈 재생성 없이 applyOptions 만 사용 */ + syncChartOverlayVisibility(): void { + if (this.mainSeries) { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.mainSeries as ISeriesApi).applyOptions({ + visible: this._chartOverlayVisibility.candle, + }); + } catch { /* ok */ } + } + for (const entry of this.indicators.values()) { + const config = entry.config; + if (!config) continue; + const key = chartOverlayKeyForType(config.type); + if (!key) continue; + + this._applySeriesVisibilityToEntry(entry); + + if (entry.type === 'BollingerBands') { + detachBbBandFill(entry); + if (this._isIndicatorEffectivelyVisible(config)) { + attachBbBandFill(entry, config); + entry.bbFillPrimitive?.requestRefresh(); + } + } + + if (entry.type === 'IchimokuCloud' && entry.cloudPlugin) { + if (!this._isIndicatorEffectivelyVisible(config)) { + entry.cloudPlugin.setCloudData([]); + } else { + void calculateIndicator( + 'IchimokuCloud', + this.rawBars, + config.params ?? {}, + ).then(({ plots }) => { + this._applyIchimokuCloudPlugin(entry.cloudPlugin!, plots, config); + }).catch(() => { /* ignore */ }); + } + } + } + } + /** 인디케이터 전체 가시성 토글 (시리즈 visible 옵션 변경) */ toggleIndicatorVisible(id: string, visible: boolean): void { const entry = this.indicators.get(id); @@ -2451,19 +2546,25 @@ export class ChartManager { config = enrichIndicatorConfig(config); const entry = this.indicators.get(config.id); if (!entry) return; - entry.config = config; + const prevUserHidden = entry.config?.hidden === true; const def = getIndicatorDef(config.type); 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]; - if (!plot) return; - const indicatorVisible = config.hidden !== true; - const visible = indicatorVisible && config.plotVisibility?.[plot.id] !== false; + const visible = indicatorVisible + && (pid == null || config.plotVisibility?.[pid] !== false); + if (!plot) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + series.applyOptions({ visible } as any); + return; + } const paneIdx = entry.paneIndex ?? 0; const indicatorPriceFormat = priceFormatForIndicatorPane(paneIdx); @@ -2516,7 +2617,7 @@ export class ChartManager { entry.fillPrimitive = undefined; } - if (config.hidden !== true) { + if (indicatorVisible) { for (const hl of hlines) { if (hl.visible === false) continue; const pl = firstSeries.createPriceLine({ @@ -2544,7 +2645,7 @@ export class ChartManager { if (entry.type === 'BollingerBands') { detachBbBandFill(entry); - if (config.hidden !== true) { + if (indicatorVisible) { attachBbBandFill(entry, config); entry.bbFillPrimitive?.requestRefresh(); } @@ -2560,7 +2661,17 @@ export class ChartManager { }).catch(() => { /* ignore */ }); } - entry.config = config; + const overlayKey = chartOverlayKeyForType(config.type); + const overlayOn = overlayKey == null || this._chartOverlayVisibility[overlayKey]; + const toStore = { ...config }; + if (overlayKey) { + if (overlayOn && config.hidden !== true) { + delete toStore.hidden; + } else if (!overlayOn && toStore.hidden === true && !prevUserHidden && config.hidden !== true) { + delete toStore.hidden; + } + } + entry.config = toStore; if (entry.seriesMeta.some(m => m.isHistogram)) { void this._repaintHistogramSeries(config.id, config); } diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index ca02607..c78c3a3 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -423,6 +423,8 @@ export interface AppSettingsDto { chartLiveReceiveHighlight?: boolean; /** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 (기본 true) */ chartCrosshairInfoVisible?: boolean; + /** 차트 마우스오버 줌·이동 플로팅 툴바 표시 (기본 true) */ + chartHoverToolbarVisible?: boolean; /** 차트 상단 범례(tv-legend) 항목별 표시 옵션 */ chartLegendOptions?: Record | null; /** 차트 pane 구분선 — mainToIndicator / indicatorToIndicator */ diff --git a/frontend/src/utils/chartOverlayVisibility.ts b/frontend/src/utils/chartOverlayVisibility.ts new file mode 100644 index 0000000..831fc15 --- /dev/null +++ b/frontend/src/utils/chartOverlayVisibility.ts @@ -0,0 +1,66 @@ +import type { IndicatorConfig } from '../types'; +import { getIndicatorDef } from './indicatorRegistry'; + +/** 실시간 차트 — 캔들 pane 오버레이 표시/숨김 (세션만, DB 미저장) */ +export type ChartOverlayToggleKey = 'ma' | 'bollinger' | 'ichimoku' | 'candle'; + +export type ChartOverlayVisibility = Record; + +export const DEFAULT_CHART_OVERLAY_VISIBILITY: ChartOverlayVisibility = { + ma: true, + bollinger: true, + ichimoku: true, + candle: true, +}; + +export const CHART_OVERLAY_TOGGLE_ORDER: ChartOverlayToggleKey[] = [ + 'ma', + 'bollinger', + 'ichimoku', + 'candle', +]; + +export const CHART_OVERLAY_LABELS: Record = { + ma: '이동평균선', + bollinger: '볼린저 밴드', + ichimoku: '일목균형표', + candle: '캔들차트', +}; + +/** 캔들 pane 오버레이 이동평균 그룹 (SMA·EMA·WMA·HMA 등 Moving Averages 카테고리) */ +export function isMovingAverageOverlayType(type: string): boolean { + const def = getIndicatorDef(type); + return def?.category === 'Moving Averages' && def.overlay === true; +} + +export function chartOverlayKeyForType(type: string): ChartOverlayToggleKey | null { + if (isMovingAverageOverlayType(type)) return 'ma'; + if (type === 'IchimokuCloud') return 'ichimoku'; + if (type === 'BollingerBands') return 'bollinger'; + return null; +} + +/** + * 오버레이 그룹 + 지표 자체 hidden 을 반영한 표시용 config (entry.config 는 변경하지 않음). + * ChartManager 는 _chartOverlayVisibility 로 직접 제어 — 이 헬퍼는 UI/필터용. + */ +export function effectiveConfigForOverlay( + config: IndicatorConfig, + visibility: ChartOverlayVisibility, +): IndicatorConfig { + const key = chartOverlayKeyForType(config.type); + if (!key) return config; + if (config.hidden === true) return config; + if (!visibility[key]) return { ...config, hidden: true }; + return config; +} + +export function listChartOverlayToggleItems( + visibility: ChartOverlayVisibility, +): Array<{ key: ChartOverlayToggleKey; label: string; visible: boolean }> { + return CHART_OVERLAY_TOGGLE_ORDER.map(key => ({ + key, + label: CHART_OVERLAY_LABELS[key], + visible: visibility[key], + })); +} diff --git a/frontend/src/utils/paperChartOverlayVisibility.ts b/frontend/src/utils/paperChartOverlayVisibility.ts index 5abf7bf..5bcdb3b 100644 --- a/frontend/src/utils/paperChartOverlayVisibility.ts +++ b/frontend/src/utils/paperChartOverlayVisibility.ts @@ -11,7 +11,7 @@ export const DEFAULT_PAPER_OVERLAY_VISIBILITY: PaperOverlayVisibility = { bollinger: true, }; -const MA_TYPES = new Set(['SMA', 'EMA']); +import { isMovingAverageOverlayType } from './chartOverlayVisibility'; export const PAPER_OVERLAY_TOGGLE_ORDER: PaperOverlayToggleKey[] = [ 'ma', @@ -29,7 +29,7 @@ export const PAPER_OVERLAY_LABELS: Record = { export const PAPER_OVERLAY_MENU_TITLE = '표시 설정'; export function paperOverlayKeyForType(type: string): PaperOverlayToggleKey | null { - if (MA_TYPES.has(type)) return 'ma'; + if (isMovingAverageOverlayType(type)) return 'ma'; if (type === 'IchimokuCloud') return 'ichimoku'; if (type === 'BollingerBands') return 'bollinger'; return null;