diff --git a/frontend/src/App.css b/frontend/src/App.css index e53fade..d8a64ce 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2526,6 +2526,26 @@ html.theme-blue { opacity: 1; } +/* 보조지표 단독 전체보기 — pane 우측 상단 복원 */ +.indicator-pane-controls { + display: flex; + pointer-events: auto; + opacity: 0.88; + transition: opacity 0.15s; +} +.indicator-pane-controls:hover { + opacity: 1; +} +.chart-right-toolbar-cluster--focus-restore { + right: 0; + left: auto; +} +.chart-right-toolbar-btn--restore.active { + color: var(--accent); + border-color: rgba(122, 162, 247, 0.65); + background: rgba(122, 162, 247, 0.22); +} + /* ── PaneLegend 드래그 핸들 ─────────────────────────────────────────── */ .pane-legend-item { /* fixed position 은 인라인 style 로 지정되므로 여기선 공통 속성만 */ diff --git a/frontend/src/components/ChartRightToolbar.tsx b/frontend/src/components/ChartRightToolbar.tsx index 259ab9d..f89a2d8 100644 --- a/frontend/src/components/ChartRightToolbar.tsx +++ b/frontend/src/components/ChartRightToolbar.tsx @@ -6,6 +6,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import type { IndicatorConfig } from '../types'; 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'; @@ -154,8 +155,8 @@ function buildMenuItems( const itemHostId = getPaneHostId(item.id, indicators); const isMergedPane = samePaneItems.length > 1; const showSplit = isMergedPane && item.id === itemHostId && !!opts.onSplit; - const isFocused = opts.focusedId === item.id; - const showExpand = !!(opts.onExpand || (isFocused && opts.onRestore)); + const isFocused = isIndicatorPaneFocused(item.id, opts.focusedId, indicators); + const showExpand = !!(opts.onExpand || (opts.focusedId && opts.onRestore)); const done = opts.onDone ?? (() => {}); const cfg = indicators.find(ind => ind.id === item.id); const isHidden = cfg?.hidden === true; @@ -302,6 +303,17 @@ const ChartRightToolbar: React.FC = ({ const showOverlayMenu = !!(candleOverlayToggles?.length && candlePane && onToggleCandleOverlay); const overlayMenuOpen = openMenuId === CANDLE_OVERLAY_MENU_ID; + const focusedLayoutId = focusedId + ? resolveFocusedPaneLayoutId(focusedId, indicators) + : null; + const focusedPaneLayout = focusedLayoutId + ? (() => { + const item = paneItems.find(p => isIndicatorPaneFocused(p.id, focusedId, indicators)); + if (item) return resolvePaneItemLayout(manager, item); + return manager.getIndicatorPaneScreenLayout(focusedLayoutId); + })() + : null; + const overlayMenuItems: MenuItemDef[] = showOverlayMenu ? candleOverlayToggles!.map(item => ({ key: item.key, @@ -320,6 +332,22 @@ const ChartRightToolbar: React.FC = ({ className="chart-right-toolbar-inner" style={{ height: Math.max(innerHeight, chartHeight) }} > + {focusedId && onRestore && focusedPaneLayout && ( +
+ +
+ )} + {showOverlayMenu && (
( + + + + + + +); + +export interface IndicatorPaneControlsProps { + manager: ChartManager; + containerEl: HTMLElement; + indicators: IndicatorConfig[]; + focusedId: string | null; + onRestore: () => void; +} + +const IndicatorPaneControls: React.FC = ({ + manager, + containerEl, + indicators, + focusedId, + onRestore, +}) => { + const [pos, setPos] = useState<{ left: number; top: number } | null>(null); + + const updatePos = useCallback(() => { + if (!focusedId) { + setPos(null); + return; + } + const layoutId = resolveFocusedPaneLayoutId(focusedId, indicators); + const lay = manager.getIndicatorPaneScreenLayout(layoutId); + if (!lay) { + setPos(null); + return; + } + const rect = containerEl.getBoundingClientRect(); + setPos({ + left: rect.left + rect.width - 40, + top: rect.top + lay.topY + 4, + }); + }, [manager, containerEl, focusedId, indicators]); + + useEffect(() => { + updatePos(); + const ro = new ResizeObserver(() => updatePos()); + ro.observe(containerEl); + const unsub = manager.subscribePaneLayout(() => updatePos()); + const id = setInterval(updatePos, 400); + return () => { + ro.disconnect(); + unsub(); + clearInterval(id); + }; + }, [containerEl, manager, updatePos, focusedId]); + + if (!focusedId || !pos) return null; + + return ( +
e.stopPropagation()} + > + +
+ ); +}; + +export default IndicatorPaneControls; diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index 8ae1253..9d31e2c 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -56,6 +56,7 @@ import ChartHoverToolbar from './ChartHoverToolbar'; import ChartMagnifier from './ChartMagnifier'; import ChartContextMenu from './ChartContextMenu'; import CandlePaneControls from './CandlePaneControls'; +import IndicatorPaneControls from './IndicatorPaneControls'; import { getKoreanName } from '../utils/marketNameCache'; import { DEFAULT_DISPLAY_TIMEZONE } from '../utils/timezone'; import { chartHasStaleIndicators, classifyIndicatorChartChange, singleIndParamKey } from '../utils/indicatorChartSync'; @@ -1667,6 +1668,15 @@ const TradingChart: React.FC = ({ onRestore={() => setCandleOnlyMode(false)} /> )} + {chartMgr && focusedIndicatorId && onRestoreIndicators && chartPaintReady && ( + containerRef.current} + focusedId={focusedIndicatorId} + onRestore={onRestoreIndicators} + /> + )} {chartMgr && !candleOnlyMode && showPaneLegend && chartPaintReady && ( ; }; +const IndicatorPaneControlsPortal: React.FC<{ + manager: ChartManager; + indicators: IndicatorConfig[]; + getContainer: () => HTMLElement | null; + focusedId: string; + onRestore: () => void; +}> = ({ manager, indicators, getContainer, focusedId, onRestore }) => { + const [el, setEl] = useState(null); + useEffect(() => { + const c = getContainer(); + if (c) { setEl(c); return; } + const tid = setTimeout(() => setEl(getContainer()), 100); + return () => clearTimeout(tid); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + if (!el) return null; + return ( + + ); +}; + const CandlePaneControlsPortal: React.FC<{ manager: ChartManager; getContainer: () => HTMLElement | null; diff --git a/frontend/src/utils/indicatorPaneFocus.ts b/frontend/src/utils/indicatorPaneFocus.ts new file mode 100644 index 0000000..2520883 --- /dev/null +++ b/frontend/src/utils/indicatorPaneFocus.ts @@ -0,0 +1,32 @@ +import type { IndicatorConfig } from '../types'; +import { getPaneHostId } from './indicatorPaneMerge'; + +/** 단독 전체보기(focusedId) 대상 pane 항목인지 — 호스트·병합 멤버 id 모두 매칭 */ +export function isIndicatorPaneFocused( + itemId: string, + focusedId: string | null | undefined, + indicators: IndicatorConfig[], +): boolean { + if (!focusedId) return false; + if (itemId === focusedId) return true; + + const itemCfg = indicators.find(i => i.id === itemId); + const focusCfg = indicators.find(i => i.id === focusedId); + if (itemCfg?.mergedWith === focusedId) return true; + if (focusCfg?.mergedWith === itemId) return true; + if (focusCfg?.mergedWith && itemCfg?.mergedWith === focusCfg.mergedWith) return true; + + const itemHost = getPaneHostId(itemId, indicators); + const focusHost = getPaneHostId(focusedId, indicators); + return itemHost === focusedId || itemHost === focusHost || itemId === focusHost; +} + +/** focusedId 에 해당하는 pane 레이아웃 조회용 id (병합 시 호스트 우선) */ +export function resolveFocusedPaneLayoutId( + focusedId: string, + indicators: IndicatorConfig[], +): string { + const cfg = indicators.find(i => i.id === focusedId); + if (cfg?.mergedWith) return cfg.mergedWith; + return getPaneHostId(focusedId, indicators); +}