diff --git a/frontend/src/App.css b/frontend/src/App.css index 939b630..39e242f 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -953,6 +953,91 @@ html.theme-blue { position: relative; width: 100%; height: 100%; + flex: 1; + min-width: 0; +} + +/* 차트 + 우측 툴바 행 */ +.chart-main-row { + display: flex; + flex: 1; + min-height: 0; + min-width: 0; + width: 100%; + position: relative; +} + +/* 우측 세로 툴바 — 가격축 라벨 오른쪽 전용 영역 */ +.chart-right-toolbar { + width: var(--sidebar-w, 44px); + flex-shrink: 0; + background: var(--bg2); + border-left: 1px solid var(--border); + position: relative; + z-index: 6; + overflow: hidden; +} + +.chart-right-toolbar-inner { + position: relative; + width: 100%; +} + +.chart-right-toolbar-segment { + position: absolute; + left: 0; + right: 0; + border-bottom: 1px solid var(--border); + pointer-events: none; + box-sizing: border-box; +} + +.chart-right-toolbar-cluster { + position: absolute; + left: 0; + right: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 0; + z-index: 2; + pointer-events: auto; +} + +.chart-right-toolbar-btn { + width: 32px !important; + height: 32px !important; + margin: 0 !important; +} + +.chart-right-toolbar-btn--menu { + position: relative !important; +} + +.chart-right-toolbar-btn--drag { + cursor: grab; + touch-action: none; +} + +.chart-right-toolbar-btn--drag:active { + cursor: grabbing; +} + +/* 우측 툴바 플라이아웃 — 버튼 왼쪽으로 펼침 */ +.tv-side-flyout--right-anchor { + transform: translateX(-100%); + animation: flyout-in-right 0.1s ease; +} + +@keyframes flyout-in-right { + from { opacity: 0; transform: translateX(calc(-100% + 6px)); } + to { opacity: 1; transform: translateX(-100%); } +} + +.tv-side-flyout-item--danger:hover { + color: #ff6b6b !important; + background: rgba(255, 107, 107, 0.15) !important; } /* PaneLegend — portal 호스트 + 레이어 (React createPortal 전용, 수동 DOM 제거 금지) */ @@ -9060,6 +9145,28 @@ html.theme-blue { line-height: 1.45; } +.stg-subsection-label { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 12.5px; + font-weight: 600; + color: var(--text); + padding: 10px 16px 2px; +} + +.stg-subsection-label--spaced { + margin-top: 6px; + border-top: 1px solid var(--border); + padding-top: 14px; +} + +.stg-subsection-desc { + font-size: 11px; + font-weight: 400; + color: var(--text3); +} + /* ── 설정 행 ── */ .stg-row { display: flex; diff --git a/frontend/src/components/ChartRightToolbar.tsx b/frontend/src/components/ChartRightToolbar.tsx new file mode 100644 index 0000000..95dbc64 --- /dev/null +++ b/frontend/src/components/ChartRightToolbar.tsx @@ -0,0 +1,339 @@ +/** + * 차트 우측 세로 툴바 — 캔들·보조지표 pane 과 높이 동기화. + * pane 하단: 드래그 핸들 + 펼침메뉴(분리·복사·전체보기·종료) + */ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import type { IndicatorConfig } from '../types'; +import { ChartManager } from '../utils/ChartManager'; +import { getPaneHostId } from '../utils/indicatorPaneMerge'; +import { buildPaneItems, resolvePaneItemLayout } from './paneLegendLayout'; + +export interface ChartRightToolbarProps { + manager: ChartManager; + indicators: IndicatorConfig[]; + chartHeight: number; + paused?: boolean; + onSplit?: (hostId: string) => void; + onRemove?: (id: string) => void; + onDuplicate?: (id: string) => void; + onExpand?: (id: string) => void; + onRestore?: () => void; + focusedId?: string | null; + onDragStart?: (id: string, e: React.PointerEvent) => void; +} + +const BTN_SIZE = 32; +const BTN_GAP = 2; + +const IconCopy = () => ( + + + + +); + +const IconSplit = () => ( + + + + + +); + +const IconClose = () => ( + + + + +); + +const IconExpand = () => ( + + + + + + +); + +const IconRestore = () => ( + + + + + + +); + +const IconDrag = () => ( + + + + + + + + +); + +const IconMore = () => ( + + + + + +); + +interface MenuItemDef { + key: string; + title: string; + icon: React.ReactNode; + active?: boolean; + danger?: boolean; + onClick: () => void; +} + +function buildMenuItems( + item: ReturnType[number], + indicators: IndicatorConfig[], + paneItems: ReturnType, + opts: { + focusedId?: string | null; + onSplit?: (hostId: string) => void; + onDuplicate?: (id: string) => void; + onExpand?: (id: string) => void; + onRestore?: () => void; + onRemove?: (id: string) => void; + onDone?: () => void; + }, +): MenuItemDef[] { + const samePaneItems = paneItems.filter(p => p.paneIndex === item.paneIndex); + 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 done = opts.onDone ?? (() => {}); + + const items: MenuItemDef[] = []; + + if (showSplit) { + items.push({ + key: 'split', + title: '보조지표 분리', + icon: , + onClick: () => { opts.onSplit?.(itemHostId); done(); }, + }); + } + if (opts.onDuplicate) { + items.push({ + key: 'copy', + title: '보조지표 복사', + icon: , + onClick: () => { opts.onDuplicate?.(item.id); done(); }, + }); + } + if (showExpand) { + items.push({ + key: 'expand', + title: isFocused ? '전체 보기로 복원' : '이 지표만 보기', + icon: isFocused ? : , + active: isFocused, + onClick: () => { + if (isFocused) opts.onRestore?.(); + else opts.onExpand?.(item.id); + done(); + }, + }); + } + if (opts.onRemove) { + items.push({ + key: 'remove', + title: '지표 제거', + icon: , + danger: true, + onClick: () => { opts.onRemove?.(item.id); done(); }, + }); + } + + return items; +} + +const ChartRightToolbar: React.FC = ({ + manager, indicators, chartHeight, paused = false, + onSplit, onRemove, onDuplicate, + onExpand, onRestore, focusedId, + onDragStart, +}) => { + const [paneLayouts, setPaneLayouts] = useState>([]); + const [paneItems, setPaneItems] = useState>([]); + const [openMenuId, setOpenMenuId] = useState(null); + const [flyoutPos, setFlyoutPos] = useState({ top: 0, left: 0 }); + const indicatorsRef = useRef(indicators); + const openTimer = useRef | null>(null); + const closeTimer = useRef | null>(null); + + const sync = useCallback(() => { + setPaneLayouts(manager.getPaneLayouts()); + setPaneItems(buildPaneItems(manager, indicatorsRef.current)); + }, [manager]); + + useEffect(() => { indicatorsRef.current = indicators; }, [indicators]); + + useEffect(() => { + if (paused) { + setPaneItems([]); + return; + } + sync(); + const unsub = manager.subscribePaneLayout(() => sync()); + return unsub; + }, [manager, paused, sync, indicators]); + + useEffect(() => () => { + if (openTimer.current) clearTimeout(openTimer.current); + if (closeTimer.current) clearTimeout(closeTimer.current); + }, []); + + const closeFlyout = useCallback(() => { + setOpenMenuId(null); + if (openTimer.current) { clearTimeout(openTimer.current); openTimer.current = null; } + if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; } + }, []); + + const openFlyout = useCallback((itemId: string, el: HTMLElement) => { + if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; } + if (openTimer.current) clearTimeout(openTimer.current); + openTimer.current = setTimeout(() => { + const rect = el.getBoundingClientRect(); + setFlyoutPos({ top: rect.top, left: rect.left - 4 }); + setOpenMenuId(itemId); + }, 80); + }, []); + + const scheduleClose = useCallback(() => { + if (openTimer.current) { clearTimeout(openTimer.current); openTimer.current = null; } + closeTimer.current = setTimeout(() => setOpenMenuId(null), 200); + }, []); + + const cancelClose = useCallback(() => { + if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; } + }, []); + + if (paused || chartHeight <= 0) return null; + + const innerHeight = paneLayouts.reduce( + (max, l) => Math.max(max, l.topY + l.height), + chartHeight, + ); + + const allowDrag = !!(onDragStart && paneItems.length > 1); + const clusterHeight = (allowDrag ? BTN_SIZE + BTN_GAP : 0) + BTN_SIZE; + + const openItem = openMenuId + ? paneItems.find(p => p.id === openMenuId) + : null; + + return ( + + ); +}; + +export default ChartRightToolbar; diff --git a/frontend/src/components/PaneLegend.tsx b/frontend/src/components/PaneLegend.tsx index b8d54cc..a011bfc 100644 --- a/frontend/src/components/PaneLegend.tsx +++ b/frontend/src/components/PaneLegend.tsx @@ -15,26 +15,15 @@ import { createPortal } from 'react-dom'; import type { MouseEventParams, Time } from 'lightweight-charts'; import type { IndicatorConfig } from '../types'; import { ChartManager } from '../utils/ChartManager'; -import { getIndicatorDef, getIndicatorChartTitle } from '../utils/indicatorRegistry'; -import { getGlobalParamKeys, getPlotParamKeys } from '../utils/indicatorSettingsLayout'; -import { formatBbLegendLabel } from '../utils/bollingerConfig'; import { bindWindowDrag, clientXYFromReact } from '../utils/pointerDrag'; import { getPaneHostId } from '../utils/indicatorPaneMerge'; +import { + buildPaneItems, + resolvePaneItemLayout, + type PaneItem, +} from './paneLegendLayout'; // ── 타입 ───────────────────────────────────────────────────────────────────── -interface PaneItem { - id: string; - paneIndex: number; - label: string; - plotColors: string[]; - hidden?: boolean; - /** 화면에 보이는 sub-pane 위치 (orphan pane 인덱스와 분리) */ - layoutTopY?: number; - layoutHeight?: number; -} - -const MIN_SUB_PANE_HEIGHT = 12; - interface PaneCapture { dataUrl: string; cssWidth: number; @@ -66,18 +55,8 @@ export interface PaneLegendProps { onReorder?: (fromId: string, insertBeforeId: string | null) => void; /** 보조지표 pane 병합 */ onMerge?: (fromId: string, intoId: string) => void; - /** 병합 pane 분리 */ - onSplit?: (hostId: string) => void; - /** 지표 단독 전체화면 확장 */ - onExpand?: (id: string) => void; - /** 지표 제거 (X 버튼) */ - onRemove?: (id: string) => void; - /** 보조지표 pane 복사 */ - onDuplicate?: (id: string) => void; - /** 현재 단독 전체화면 중인 지표 id */ - focusedId?: string | null; - /** 전체화면 → 전체 보기 복원 */ - onRestore?: () => void; + /** 드래그 핸들 — ChartRightToolbar 에서 호출 */ + dragHandleRef?: React.MutableRefObject<((id: string, e: React.PointerEvent) => void) | null>; /** 크로스헤어 이동 시 보조지표 수치 표시 */ crosshairInfoVisible?: boolean; } @@ -159,138 +138,7 @@ function computeShiftY( return 0; } -function makeLabel(config: IndicatorConfig): string { - if (config.type === 'BollingerBands') { - return formatBbLegendLabel(config.params ?? {}); - } - const name = getIndicatorChartTitle(config.type); - const def = getIndicatorDef(config.type); - const params = (config.params ?? def?.defaultParams ?? {}) as Record; - const plots = config.plots ?? def?.plots ?? []; - - const orderedKeys: string[] = []; - for (let i = 0; i < plots.length; i++) { - for (const k of getPlotParamKeys(config.type, plots[i].id, i, plots, params)) { - if (!orderedKeys.includes(k)) orderedKeys.push(k); - } - } - for (const k of getGlobalParamKeys(config.type, params, plots)) { - if (!orderedKeys.includes(k) && typeof params[k] === 'number') orderedKeys.push(k); - } - - const nums = orderedKeys - .map(k => params[k]) - .filter((v): v is number => typeof v === 'number') - .slice(0, 3); - return nums.length ? `${name} ${nums.join(', ')}` : name; -} - -function applyPaneLayoutToItem( - item: PaneItem, - layout: { topY: number; height: number }, - overwrite = false, -): void { - if (!overwrite && item.layoutTopY != null) return; - item.layoutTopY = layout.topY; - item.layoutHeight = layout.height; -} - -function buildPaneItems( - manager: ChartManager, - indicators: IndicatorConfig[], -): PaneItem[] { - const wantedIds = new Set( - indicators - .filter(ind => { - const def = getIndicatorDef(ind.type); - return def && !def.overlay && !def.returnsMarkers; - }) - .map(ind => ind.id), - ); - - const configById = new Map(indicators.map(ind => [ind.id, ind])); - - // ChartManager 에 실제 로드된 지표만 — React state 만 있고 pane 미부착인 항목 제외 - const items: PaneItem[] = []; - const seenIds = new Set(); - for (const info of manager.getIndicatorPaneInfo()) { - if (!wantedIds.has(info.id) || seenIds.has(info.id)) continue; - seenIds.add(info.id); - const ind = configById.get(info.id) ?? info.config; - const def = getIndicatorDef(ind.type); - if (!def || def.overlay || def.returnsMarkers) continue; - - let chartPane = info.paneIndex; - if (ind.mergedWith) { - const host = manager.getIndicatorPaneIndex(ind.mergedWith); - if (host >= 2) chartPane = host; - } - - items.push({ - id: info.id, - paneIndex: chartPane, - label: makeLabel(ind), - plotColors: info.plotColors.map(c => c.slice(0, 7)), - hidden: ind.hidden === true, - }); - } - - if (items.length === 0) return []; - - items.sort((a, b) => a.paneIndex - b.paneIndex); - - for (const item of items) { - const lay = manager.getIndicatorPaneScreenLayout(item.id); - if (lay) applyPaneLayoutToItem(item, lay, true); - } - - items.sort((a, b) => { - const ay = a.layoutTopY ?? 1e9; - const by = b.layoutTopY ?? 1e9; - if (ay !== by) return ay - by; - return a.paneIndex - b.paneIndex; - }); - - return items.filter( - item => item.layoutTopY != null && (item.layoutHeight ?? 0) > MIN_SUB_PANE_HEIGHT, - ); -} - -/** 렌더 시점 pane 좌표 — 시리즈 DOM 기준만 사용 (stale paneIndex 폴백 제거) */ -function resolvePaneItemLayout( - manager: ChartManager, - item: PaneItem, -): { topY: number; height: number } | null { - return manager.getIndicatorPaneScreenLayout(item.id); -} - // ── 아이콘 SVG ──────────────────────────────────────────────────────────────── -/** ⤡ 전체화면 확장 — 네 꼭지점 바깥 방향 화살표 */ -const IconExpand = () => ( - - {/* 좌하단 → 좌하 */} - - - {/* 우상단 → 우상 */} - - - -); - -/** ⤢ 전체화면 복원 — 네 꼭지점 안쪽 방향 화살표 */ -const IconRestore = () => ( - - {/* 좌하단 → 중앙 */} - - - {/* 우상단 → 중앙 */} - - - -); - const IconMerge = () => ( @@ -299,37 +147,11 @@ const IconMerge = () => ( ); -/** 그래프 복사 — 겹친 사각형 */ -const IconCopy = () => ( - - - - -); - -const IconSplit = () => ( - - - - - -); - -const IconClose = () => ( - - - - -); - // ── 컴포넌트 ────────────────────────────────────────────────────────────────── const PaneLegend: React.FC = ({ manager, indicators, containerEl, paused = false, onHoverName, onLeaveName, onDoubleClick, - onReorder, onMerge, onSplit, onExpand, onRemove, onDuplicate, focusedId, onRestore, + onReorder, onMerge, dragHandleRef, crosshairInfoVisible = true, }) => { const [paneItems, setPaneItems] = useState([]); @@ -350,8 +172,6 @@ const PaneLegend: React.FC = ({ const containerElRef = useRef(containerEl); const onReorderRef = useRef(onReorder); const onMergeRef = useRef(onMerge); - const onSplitRef = useRef(onSplit); - const onDuplicateRef = useRef(onDuplicate); const indicatorsRef = useRef(indicators); const [portalHost, setPortalHost] = useState(null); @@ -373,8 +193,6 @@ const PaneLegend: React.FC = ({ useEffect(() => { containerElRef.current = containerEl; }, [containerEl]); useEffect(() => { onReorderRef.current = onReorder; }, [onReorder]); useEffect(() => { onMergeRef.current = onMerge; }, [onMerge]); - useEffect(() => { onSplitRef.current = onSplit; }, [onSplit]); - useEffect(() => { onDuplicateRef.current = onDuplicate; }, [onDuplicate]); useEffect(() => { indicatorsRef.current = indicators; }, [indicators]); const syncPaneItems = useCallback(() => { @@ -587,10 +405,15 @@ const PaneLegend: React.FC = ({ finishDrag, ); document.addEventListener('keydown', handleKeyDown); - }, []); + }, [manager]); + + useEffect(() => { + if (!dragHandleRef) return; + dragHandleRef.current = (id, e) => handleDragHandlePointerDown(e, id); + return () => { dragHandleRef.current = null; }; + }, [dragHandleRef, handleDragHandlePointerDown]); // ── 렌더 ───────────────────────────────────────────────────────────────── - const canReorder = !!onReorder && paneItems.length > 1; const isDragging = dragState !== null; const dropLineY = isDragging && dragState!.dropMode === 'reorder' ? getDropLineY(dragState!.dropBeforeIndex) @@ -625,23 +448,13 @@ const PaneLegend: React.FC = ({ const pl = resolvePaneItemLayout(manager, item); if (!pl) return null; - const paneBottomY = pl.topY + pl.height; const itemVals = item.hidden ? [] : (values[item.id] ?? []); const itemHostId = getPaneHostId(item.id, indicators); const samePaneItems = paneItems.filter(p => p.paneIndex === item.paneIndex); const stackIdx = samePaneItems.findIndex(p => p.id === item.id); const isBeingDragged = isDragging && itemHostId === draggingHostId; - const isFocused = focusedId === item.id; - const isMergedPane = samePaneItems.length > 1; - const showSplitBtn = isMergedPane && item.id === itemHostId && !!onSplit; - const PANE_BTN_STEP = 24; - const paneBtnRight = (slot: number) => 28 + slot * PANE_BTN_STEP; const hoverScreenX = containerRect.left; const hoverScreenY = containerRect.top + pl.topY; - let btnSlot = 0; - const closeSlot = btnSlot++; - const copySlot = onDuplicate ? btnSlot++ : -1; - const splitSlot = showSplitBtn ? btnSlot++ : -1; const isPaneHidden = item.hidden === true; const bgColor = PANE_BG_COLORS[itemIdx % PANE_BG_COLORS.length]; @@ -711,126 +524,6 @@ const PaneLegend: React.FC = ({ ))} - - {/* ── 우측 상단: 분리 / 복사 / 닫기 ─────────────────────── */} - {showSplitBtn && splitSlot >= 0 && ( - - )} - {copySlot >= 0 && ( - - )} - {onRemove && ( - - )} - - {/* ── 좌측 하단: 전체화면 버튼 (위) + 드래그 핸들 (아래) ── */} - {/* 드래그 핸들: pane 하단에서 30px 위 */} - {canReorder && ( -
handleDragHandlePointerDown(e, item.id)} - onMouseEnter={onLeaveName} - title="드래그하여 순서 변경" - > - - - - - - - - -
- )} - - {/* 전체화면 / 복원 버튼: 드래그 핸들 위에 배치 */} - {(onExpand || (isFocused && onRestore)) && ( - - )} ); })} diff --git a/frontend/src/components/PaneSeparatorOverlay.tsx b/frontend/src/components/PaneSeparatorOverlay.tsx index 42a4c02..5795130 100644 --- a/frontend/src/components/PaneSeparatorOverlay.tsx +++ b/frontend/src/components/PaneSeparatorOverlay.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useRef } from 'react'; import type { ChartManager } from '../utils/ChartManager'; -import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator'; +import type { ChartPaneSeparatorOptions, PaneSeparatorStyle } from '../types/chartPaneSeparator'; +import { classifyPaneBoundary } from '../types/chartPaneSeparator'; import { plotColorCss } from '../utils/plotColorUtils'; interface Props { @@ -8,12 +9,29 @@ interface Props { options: ChartPaneSeparatorOptions; } -function applyLineDash(ctx: CanvasRenderingContext2D, style: ChartPaneSeparatorOptions['lineStyle']) { +function applyLineDash(ctx: CanvasRenderingContext2D, style: PaneSeparatorStyle['lineStyle']) { if (style === 'dashed') ctx.setLineDash([6, 4]); else if (style === 'dotted') ctx.setLineDash([2, 3]); else ctx.setLineDash([]); } +function strokeBoundary( + ctx: CanvasRenderingContext2D, + cssW: number, + y: number, + style: PaneSeparatorStyle, +) { + if (!style.visible) return; + ctx.strokeStyle = plotColorCss(style.color); + ctx.lineWidth = style.width; + applyLineDash(ctx, style.lineStyle); + const lineY = y + style.width / 2; + ctx.beginPath(); + ctx.moveTo(0, lineY); + ctx.lineTo(cssW, lineY); + ctx.stroke(); +} + const PaneSeparatorOverlay: React.FC = ({ manager, options }) => { const canvasRef = useRef(null); @@ -37,24 +55,22 @@ const PaneSeparatorOverlay: React.FC = ({ manager, options }) => { ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, cssW, cssH); - if (!options.visible) return; - const layouts = manager.getPaneLayouts() .filter(l => l.height > 8) .sort((a, b) => a.topY - b.topY); if (layouts.length < 2) return; - ctx.strokeStyle = plotColorCss(options.color); - ctx.lineWidth = options.width; - applyLineDash(ctx, options.lineStyle); - for (let i = 0; i < layouts.length - 1; i++) { - const y = layouts[i].topY + layouts[i].height; - const lineY = y + options.width / 2; - ctx.beginPath(); - ctx.moveTo(0, lineY); - ctx.lineTo(cssW, lineY); - ctx.stroke(); + const lower = layouts[i]; + const upper = layouts[i + 1]; + const kind = classifyPaneBoundary(lower.paneIndex, upper.paneIndex); + if (kind === 'other') continue; + + const style = kind === 'mainToIndicator' + ? options.mainToIndicator + : options.indicatorToIndicator; + const y = lower.topY + lower.height; + strokeBoundary(ctx, cssW, y, style); } }, [manager, options]); diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index c118fab..a65355b 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -37,7 +37,7 @@ import { CHART_LEGEND_SETTING_ITEMS, type ChartLegendVisibility, } from '../types/chartLegend'; -import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator'; +import type { ChartPaneSeparatorOptions, PaneSeparatorStyle } from '../types/chartPaneSeparator'; import { LINE_STYLE_LABELS, LINE_STYLE_OPTIONS, @@ -661,6 +661,72 @@ const GeneralPanel: React.FC<{ ); }; +function PaneSeparatorStyleFields({ + style, + onChange, +}: { + style: PaneSeparatorStyle; + onChange: (next: PaneSeparatorStyle) => void; +}) { + return ( + <> + + + + {style.visible && ( + <> + +
+ onChange({ ...style, color: e.target.value })} + /> + {style.color} +
+
+ + + + + + + + )} + + ); +} + interface ChartPanelProps { chartRealtimeSource?: string; onChartRealtimeSource?: (v: string) => void; @@ -820,70 +886,28 @@ const ChartPanel: React.FC = ({ {chartPaneSeparator && onChartPaneSeparatorChange && ( - - - - {chartPaneSeparator.visible && ( - <> - -
- onChartPaneSeparatorChange({ - ...chartPaneSeparator, - color: e.target.value, - })} - /> - {chartPaneSeparator.color} -
-
- - - - - - - - )} +
+ 캔들·보조지표 + 캔들(·거래량) 영역과 첫 보조지표 pane 사이 +
+ onChartPaneSeparatorChange({ + ...chartPaneSeparator, + mainToIndicator, + })} + /> +
+ 보조지표 간 + 보조지표 pane 사이 +
+ onChartPaneSeparatorChange({ + ...chartPaneSeparator, + indicatorToIndicator, + })} + />
)} diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index f0c2458..6da9c1f 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -46,6 +46,7 @@ function canApplyChartBars( } import DrawingCanvas, { hitTestDrawing } from './DrawingCanvas'; import PaneLegend, { type PaneLegendProps } from './PaneLegend'; +import ChartRightToolbar from './ChartRightToolbar'; import CandlePaneTimeAxis from './CandlePaneTimeAxis'; import ChartHoverToolbar from './ChartHoverToolbar'; import ChartMagnifier from './ChartMagnifier'; @@ -225,6 +226,7 @@ const TradingChart: React.FC = ({ const prevIndicatorsListRef = useRef(indicators); const indicatorSyncInFlightRef = useRef(false); const [paneLegendPaused, setPaneLegendPaused] = useState(false); + const [chartBodyHeight, setChartBodyHeight] = useState(0); const pendingIndicatorResyncRef = useRef(false); const indicatorReloadGenRef = useRef(0); const prevMarket = useRef(market); @@ -242,6 +244,7 @@ const TradingChart: React.FC = ({ const [chartMgr, setChartMgr] = useState(null); /** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */ const [candleOnlyMode, setCandleOnlyMode] = useState(false); + const paneDragRef = useRef<((id: string, e: React.PointerEvent) => void) | null>(null); useEffect(() => { if (barsMarket === market) { @@ -259,6 +262,16 @@ const TradingChart: React.FC = ({ chartVisibleRef.current = chartVisible; }, [chartVisible]); + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const update = () => setChartBodyHeight(el.clientHeight); + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => ro.disconnect(); + }, [chartMgr]); + const toggleCandleOnly = useCallback(() => { setCandleOnlyMode(v => !v); }, []); @@ -1360,10 +1373,27 @@ const TradingChart: React.FC = ({ style={{ flex: 1, position: 'relative', minHeight: 0 }} onPointerMove={handleWrapperMouseMove} > -
+
+
+ {chartMgr && !candleOnlyMode && showPaneLegend && ( + paneDragRef.current?.(id, e)} + /> + )} +
{ctxMenu && ( = ({ onDoubleClick={(id) => onSeriesDoubleClick?.(id, { x: 0, y: 0 })} onReorder={onReorderIndicators} onMerge={onMergeIndicators} - onSplit={onSplitIndicatorPane} - onExpand={onExpandIndicator} - onRemove={onRemoveIndicator} - onDuplicate={onDuplicateIndicator} - focusedId={focusedIndicatorId} - onRestore={onRestoreIndicators} + dragHandleRef={paneDragRef} crosshairInfoVisible={crosshairInfoVisible} /> )} diff --git a/frontend/src/components/paneLegendLayout.ts b/frontend/src/components/paneLegendLayout.ts new file mode 100644 index 0000000..b0c4b5a --- /dev/null +++ b/frontend/src/components/paneLegendLayout.ts @@ -0,0 +1,123 @@ +/** + * PaneLegend / ChartRightToolbar 공통 — pane 항목·좌표 계산 + */ +import type { IndicatorConfig } from '../types'; +import { ChartManager } from '../utils/ChartManager'; +import { getIndicatorDef, getIndicatorChartTitle } from '../utils/indicatorRegistry'; +import { getGlobalParamKeys, getPlotParamKeys } from '../utils/indicatorSettingsLayout'; +import { formatBbLegendLabel } from '../utils/bollingerConfig'; + +export interface PaneItem { + id: string; + paneIndex: number; + label: string; + plotColors: string[]; + hidden?: boolean; + layoutTopY?: number; + layoutHeight?: number; +} + +export const MIN_SUB_PANE_HEIGHT = 12; + +function makeLabel(config: IndicatorConfig): string { + if (config.type === 'BollingerBands') { + return formatBbLegendLabel(config.params ?? {}); + } + const name = getIndicatorChartTitle(config.type); + const def = getIndicatorDef(config.type); + const params = (config.params ?? def?.defaultParams ?? {}) as Record; + const plots = config.plots ?? def?.plots ?? []; + + const orderedKeys: string[] = []; + for (let i = 0; i < plots.length; i++) { + for (const k of getPlotParamKeys(config.type, plots[i].id, i, plots, params)) { + if (!orderedKeys.includes(k)) orderedKeys.push(k); + } + } + for (const k of getGlobalParamKeys(config.type, params, plots)) { + if (!orderedKeys.includes(k) && typeof params[k] === 'number') orderedKeys.push(k); + } + + const nums = orderedKeys + .map(k => params[k]) + .filter((v): v is number => typeof v === 'number') + .slice(0, 3); + return nums.length ? `${name} ${nums.join(', ')}` : name; +} + +function applyPaneLayoutToItem( + item: PaneItem, + layout: { topY: number; height: number }, + overwrite = false, +): void { + if (!overwrite && item.layoutTopY != null) return; + item.layoutTopY = layout.topY; + item.layoutHeight = layout.height; +} + +export function buildPaneItems( + manager: ChartManager, + indicators: IndicatorConfig[], +): PaneItem[] { + const wantedIds = new Set( + indicators + .filter(ind => { + const def = getIndicatorDef(ind.type); + return def && !def.overlay && !def.returnsMarkers; + }) + .map(ind => ind.id), + ); + + const configById = new Map(indicators.map(ind => [ind.id, ind])); + + const items: PaneItem[] = []; + const seenIds = new Set(); + for (const info of manager.getIndicatorPaneInfo()) { + if (!wantedIds.has(info.id) || seenIds.has(info.id)) continue; + seenIds.add(info.id); + const ind = configById.get(info.id) ?? info.config; + const def = getIndicatorDef(ind.type); + if (!def || def.overlay || def.returnsMarkers) continue; + + let chartPane = info.paneIndex; + if (ind.mergedWith) { + const host = manager.getIndicatorPaneIndex(ind.mergedWith); + if (host >= 2) chartPane = host; + } + + items.push({ + id: info.id, + paneIndex: chartPane, + label: makeLabel(ind), + plotColors: info.plotColors.map(c => c.slice(0, 7)), + hidden: ind.hidden === true, + }); + } + + if (items.length === 0) return []; + + items.sort((a, b) => a.paneIndex - b.paneIndex); + + for (const item of items) { + const lay = manager.getIndicatorPaneScreenLayout(item.id); + if (lay) applyPaneLayoutToItem(item, lay, true); + } + + items.sort((a, b) => { + const ay = a.layoutTopY ?? 1e9; + const by = b.layoutTopY ?? 1e9; + if (ay !== by) return ay - by; + return a.paneIndex - b.paneIndex; + }); + + return items.filter( + item => item.layoutTopY != null && (item.layoutHeight ?? 0) > MIN_SUB_PANE_HEIGHT, + ); +} + +export function resolvePaneItemLayout( + manager: ChartManager, + item: PaneItem, +): { topY: number; height: number } | null { + return manager.getIndicatorPaneScreenLayout(item.id); +} diff --git a/frontend/src/types/chartPaneSeparator.ts b/frontend/src/types/chartPaneSeparator.ts index e9da110..66b0665 100644 --- a/frontend/src/types/chartPaneSeparator.ts +++ b/frontend/src/types/chartPaneSeparator.ts @@ -1,20 +1,21 @@ import type { Theme } from '../types'; import type { HLineStyle } from '../utils/indicatorRegistry'; -/** 캔들·거래량·보조지표 pane 사이 구분선 */ -export interface ChartPaneSeparatorOptions { +/** 단일 pane 구분선 스타일 */ +export interface PaneSeparatorStyle { visible: boolean; color: string; width: 1 | 2 | 3 | 4; lineStyle: HLineStyle; } -export const DEFAULT_CHART_PANE_SEPARATOR: ChartPaneSeparatorOptions = { - visible: true, - color: '#2a2e3a', - width: 1, - lineStyle: 'solid', -}; +/** 캔들·보조지표 / 보조지표 간 구분선 설정 */ +export interface ChartPaneSeparatorOptions { + /** 캔들(·거래량) 영역 ↔ 첫 보조지표 pane */ + mainToIndicator: PaneSeparatorStyle; + /** 보조지표 pane 사이 */ + indicatorToIndicator: PaneSeparatorStyle; +} const THEME_DEFAULT_COLORS: Record = { dark: 'rgba(122, 162, 247, 0.15)', @@ -26,11 +27,23 @@ export function themeDefaultPaneSeparatorColor(theme: Theme): string { return THEME_DEFAULT_COLORS[theme] ?? THEME_DEFAULT_COLORS.dark; } -export function resolveChartPaneSeparatorOptions( - raw?: Partial | null, +export const DEFAULT_PANE_SEPARATOR_STYLE: PaneSeparatorStyle = { + visible: true, + color: '#2a2e3a', + width: 1, + lineStyle: 'solid', +}; + +export const DEFAULT_CHART_PANE_SEPARATOR: ChartPaneSeparatorOptions = { + mainToIndicator: { ...DEFAULT_PANE_SEPARATOR_STYLE }, + indicatorToIndicator: { ...DEFAULT_PANE_SEPARATOR_STYLE }, +}; + +export function resolvePaneSeparatorStyle( + raw?: Partial | null, theme: Theme = 'dark', -): ChartPaneSeparatorOptions { - const base = { ...DEFAULT_CHART_PANE_SEPARATOR }; +): PaneSeparatorStyle { + const base = { ...DEFAULT_PANE_SEPARATOR_STYLE }; if (!raw || typeof raw !== 'object') { return { ...base, color: themeDefaultPaneSeparatorColor(theme) }; } @@ -45,8 +58,52 @@ export function resolveChartPaneSeparatorOptions( return base; } +/** @deprecated 이전 단일 구분선 형식 — 하위 호환 */ +function isLegacyFlatSeparator( + raw: Record, +): raw is Partial { + return 'visible' in raw && !('mainToIndicator' in raw); +} + +export function resolveChartPaneSeparatorOptions( + raw?: Partial | Record | null, + theme: Theme = 'dark', +): ChartPaneSeparatorOptions { + if (!raw || typeof raw !== 'object') { + const style = resolvePaneSeparatorStyle(null, theme); + return { + mainToIndicator: { ...style }, + indicatorToIndicator: { ...style }, + }; + } + if (isLegacyFlatSeparator(raw)) { + const legacy = resolvePaneSeparatorStyle(raw, theme); + return { + mainToIndicator: { ...legacy }, + indicatorToIndicator: { ...legacy }, + }; + } + const r = raw as Partial; + return { + mainToIndicator: resolvePaneSeparatorStyle(r.mainToIndicator, theme), + indicatorToIndicator: resolvePaneSeparatorStyle(r.indicatorToIndicator, theme), + }; +} + +export type PaneBoundaryKind = 'mainToIndicator' | 'indicatorToIndicator' | 'other'; + +/** 인접 pane 경계 종류 (pane 0=캔들, 1=거래량, 2+=보조지표) */ +export function classifyPaneBoundary( + lowerPaneIndex: number, + upperPaneIndex: number, +): PaneBoundaryKind { + if (upperPaneIndex >= 2 && lowerPaneIndex < 2) return 'mainToIndicator'; + if (lowerPaneIndex >= 2 && upperPaneIndex >= 2) return 'indicatorToIndicator'; + return 'other'; +} + /** LWC pane 구분선 — 커스텀 오버레이 사용 시 투명 처리 */ -export function applyPaneSeparatorToChartOptions(opts: ChartPaneSeparatorOptions) { +export function applyPaneSeparatorToChartOptions(_opts: ChartPaneSeparatorOptions) { return { layout: { panes: { diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index 8f4e17d..de5ae31 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -2717,27 +2717,28 @@ export class ChartManager { /** 우측 가격축 라벨 영역 클릭 여부 (LWC 축 스케일 드래그에 위임) */ isOnPriceAxis(chartX: number, chartY: number): boolean { const paneIndex = this.getPaneIndexAtChartY(chartY); + const scaleW = this.getPriceScaleWidth(paneIndex); + if (scaleW <= 0) return false; + return chartX >= this.container.clientWidth - scaleW; + } + + /** pane 우측 가격축(값 라벨) 너비 — PaneLegend 버튼 배치용 */ + getPriceScaleWidth(paneIndex: number): number { try { const ps = paneIndex === 1 ? this.chart.priceScale('volume', 1) : this.chart.priceScale('right', paneIndex); const scaleW = ps.width(); - if (scaleW <= 0) return false; - return chartX >= this.container.clientWidth - scaleW; + return scaleW > 0 ? scaleW : 56; } catch { - return chartX >= this.container.clientWidth - 56; + return 56; } } /** 캔들 pane(0) 플롯 영역 너비 (우측 가격축 제외) */ private _candlePlotWidth(): number { - try { - const ps = this.mainSeries?.priceScale() ?? this.chart.priceScale('right', 0); - const scaleW = ps.width(); - return Math.max(40, this.container.clientWidth - scaleW); - } catch { - return Math.max(40, this.container.clientWidth - 56); - } + const scaleW = this.getPriceScaleWidth(0); + return Math.max(40, this.container.clientWidth - scaleW); } /** diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 8a06da6..6e9f74d 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -396,8 +396,21 @@ export interface AppSettingsDto { chartCrosshairInfoVisible?: boolean; /** 차트 상단 범례(tv-legend) 항목별 표시 옵션 */ chartLegendOptions?: Record | null; - /** 차트 pane(캔들·거래량·보조지표) 구분선 */ + /** 차트 pane 구분선 — mainToIndicator / indicatorToIndicator */ chartPaneSeparator?: { + mainToIndicator?: { + visible?: boolean; + color?: string; + width?: number; + lineStyle?: string; + }; + indicatorToIndicator?: { + visible?: boolean; + color?: string; + width?: number; + lineStyle?: string; + }; + /** @deprecated 이전 단일 형식 */ visible?: boolean; color?: string; width?: number; diff --git a/packages/shared/src/api/backendApi.ts b/packages/shared/src/api/backendApi.ts index be05b89..7070ce9 100644 --- a/packages/shared/src/api/backendApi.ts +++ b/packages/shared/src/api/backendApi.ts @@ -478,8 +478,20 @@ export interface AppSettingsDto { chartLiveReceiveHighlight?: boolean; /** 차트 상단 범례(tv-legend) 항목별 표시 옵션 */ chartLegendOptions?: Record | null; - /** 차트 pane(캔들·거래량·보조지표) 구분선 */ + /** 차트 pane 구분선 — mainToIndicator / indicatorToIndicator */ chartPaneSeparator?: { + mainToIndicator?: { + visible?: boolean; + color?: string; + width?: number; + lineStyle?: string; + }; + indicatorToIndicator?: { + visible?: boolean; + color?: string; + width?: number; + lineStyle?: string; + }; visible?: boolean; color?: string; width?: number;