diff --git a/frontend/src/App.css b/frontend/src/App.css index d862999..6891998 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -914,10 +914,33 @@ html.theme-blue { - height: 100% : 기본 상태에서 래퍼를 가득 채움 - JS 에서 스크롤 필요 시 명시적 height (e.g. 900px)로 전환 */ .chart-container { + position: relative; width: 100%; height: 100%; } +/* PaneLegend — portal 호스트 + 레이어 (React createPortal 전용, 수동 DOM 제거 금지) */ +.pane-legend-portal-host { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 5; + overflow: visible; +} + +.pane-legend-layer { + position: absolute; + inset: 0; + pointer-events: none; + overflow: visible; +} + +.pane-legend-layer .pane-legend-item, +.pane-legend-layer .pane-drag-handle, +.pane-legend-layer .pane-btn { + pointer-events: auto; +} + /* 캔들 pane 하단 시간축 (거래량·보조지표가 아래에 있을 때) */ .candle-pane-time-axis { position: absolute; diff --git a/frontend/src/components/PaneLegend.tsx b/frontend/src/components/PaneLegend.tsx index 43f4e1b..a72f278 100644 --- a/frontend/src/components/PaneLegend.tsx +++ b/frontend/src/components/PaneLegend.tsx @@ -2,6 +2,7 @@ * PaneLegend * 각 서브 pane 좌측 상단에 인디케이터 명칭 + 현재 크로스헤어 값 표시. * 드래그 핸들(⠿) / 전체화면 / 닫기 버튼 포함. + * 레이아웃 갱신 시 top transition 을 쓰지 않음 — 설정 변경 직후 라벨 이중 표시 방지. * * ─── 패인 인덱스 구조 ─────────────────────────────────────────────────────── * Pane 0 : 메인 캔들차트 @@ -10,6 +11,7 @@ * ──────────────────────────────────────────────────────────────────────────── */ import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { createPortal } from 'react-dom'; import type { MouseEventParams, Time } from 'lightweight-charts'; import type { IndicatorConfig } from '../types'; import { ChartManager } from '../utils/ChartManager'; @@ -56,6 +58,8 @@ export interface PaneLegendProps { manager: ChartManager; indicators: IndicatorConfig[]; containerEl: HTMLElement; + /** 지표 파라미터 동기화 중 — 잘못된 좌표로 라벨이 겹쳐 보이는 것 방지 */ + paused?: boolean; onHoverName: (id: string, sx: number, sy: number) => void; onLeaveName: () => void; onDoubleClick?: (id: string) => void; @@ -179,16 +183,6 @@ function makeLabel(config: IndicatorConfig): string { return nums.length ? `${name} ${nums.join(', ')}` : name; } -/** paneIndex ↔ LWC pane DOM 위치 (배열 순서·orphan pane 과 무관) */ -function layoutForPaneIndex( - paneIndex: number, - layouts: Array<{ paneIndex: number; topY: number; height: number }>, -): { topY: number; height: number } | null { - if (paneIndex < 2) return null; - const lay = layouts.find(l => l.paneIndex === paneIndex && l.height > MIN_SUB_PANE_HEIGHT); - return lay ? { topY: lay.topY, height: lay.height } : null; -} - function applyPaneLayoutToItem( item: PaneItem, layout: { topY: number; height: number }, @@ -199,43 +193,6 @@ function applyPaneLayoutToItem( item.layoutHeight = layout.height; } -/** paneIndex 기준으로만 좌표 보강 (지표 배열 순서와 화면 pane 순서가 달라도 안전) */ -function fillMissingLayoutsByPaneIndex( - items: PaneItem[], - layouts: Array<{ paneIndex: number; topY: number; height: number }>, -): void { - for (const item of items) { - if (item.layoutTopY != null || item.paneIndex < 2) continue; - const lay = layoutForPaneIndex(item.paneIndex, layouts); - if (lay) applyPaneLayoutToItem(item, lay); - } -} - -/** - * 실제 시리즈가 붙은 sub-pane 만으로 좌표 보강 (orphan 빈 pane 제외). - * paneIndex 미등록(-1)·탭 일괄 교체 직후에도 레이블 위치를 맞춘다. - */ -function fillMissingLayoutsFromActiveSubPanes( - items: PaneItem[], - subLayouts: Array<{ paneIndex: number; topY: number; height: number }>, -): void { - if (subLayouts.length === 0) return; - - const missing = items.filter(i => i.layoutTopY == null); - if (missing.length === 0) return; - - const usedPane = new Set(); - const sorted = [...missing].sort((a, b) => a.paneIndex - b.paneIndex); - - for (const item of sorted) { - if (item.paneIndex < 2) continue; - const lay = subLayouts.find(l => l.paneIndex === item.paneIndex && !usedPane.has(l.paneIndex)); - if (!lay) continue; - applyPaneLayoutToItem(item, lay); - usedPane.add(lay.paneIndex); - } -} - function buildPaneItems( manager: ChartManager, indicators: IndicatorConfig[], @@ -249,11 +206,15 @@ function buildPaneItems( .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)) continue; - const ind = info.config; + 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; @@ -276,16 +237,11 @@ function buildPaneItems( items.sort((a, b) => a.paneIndex - b.paneIndex); - const subLayouts = manager.getIndicatorSubPaneLayouts(); - for (const item of items) { const lay = manager.getIndicatorPaneScreenLayout(item.id); if (lay) applyPaneLayoutToItem(item, lay, true); } - fillMissingLayoutsByPaneIndex(items, subLayouts); - fillMissingLayoutsFromActiveSubPanes(items, subLayouts); - items.sort((a, b) => { const ay = a.layoutTopY ?? 1e9; const by = b.layoutTopY ?? 1e9; @@ -293,31 +249,17 @@ function buildPaneItems( return a.paneIndex - b.paneIndex; }); - return items; -} - -function paneItemLayout( - item: PaneItem, - layouts: Array<{ paneIndex: number; topY: number; height: number }>, -): { topY: number; height: number } | null { - if (item.layoutTopY != null && item.layoutHeight != null) { - return { topY: item.layoutTopY, height: item.layoutHeight }; - } - const l = layouts.find( - x => x.paneIndex === item.paneIndex && x.height > MIN_SUB_PANE_HEIGHT, + return items.filter( + item => item.layoutTopY != null && (item.layoutHeight ?? 0) > MIN_SUB_PANE_HEIGHT, ); - return l ? { topY: l.topY, height: l.height } : null; } -/** 렌더 시점 pane 좌표 — resetPaneHeights 후 캐시 topY 가 어긋나지 않도록 시리즈 DOM 우선 */ +/** 렌더 시점 pane 좌표 — 시리즈 DOM 기준만 사용 (stale paneIndex 폴백 제거) */ function resolvePaneItemLayout( manager: ChartManager, item: PaneItem, - layouts: Array<{ paneIndex: number; topY: number; height: number }>, ): { topY: number; height: number } | null { - const live = manager.getIndicatorPaneScreenLayout(item.id); - if (live) return live; - return paneItemLayout(item, layouts); + return manager.getIndicatorPaneScreenLayout(item.id); } // ── 아이콘 SVG ──────────────────────────────────────────────────────────────── @@ -383,7 +325,7 @@ const IconClose = () => ( // ── 컴포넌트 ────────────────────────────────────────────────────────────────── const PaneLegend: React.FC = ({ - manager, indicators, containerEl, + manager, indicators, containerEl, paused = false, onHoverName, onLeaveName, onDoubleClick, onReorder, onMerge, onSplit, onExpand, onRemove, onDuplicate, focusedId, onRestore, }) => { @@ -408,6 +350,20 @@ const PaneLegend: React.FC = ({ const onSplitRef = useRef(onSplit); const onDuplicateRef = useRef(onDuplicate); const indicatorsRef = useRef(indicators); + const [portalHost, setPortalHost] = useState(null); + + /** React portal 전용 호스트 — chart-container 직접 조작·removeChild 경합 방지 */ + useEffect(() => { + const sel = ':scope > .pane-legend-portal-host'; + let host = containerEl.querySelector(sel); + if (!host) { + host = document.createElement('div'); + host.className = 'pane-legend-portal-host'; + containerEl.appendChild(host); + } + setPortalHost(host); + return () => { setPortalHost(null); }; + }, [containerEl]); useEffect(() => { paneItemsRef.current = paneItems; }, [paneItems]); useEffect(() => { paneLayoutsRef.current = paneLayouts; }, [paneLayouts]); @@ -420,8 +376,9 @@ const PaneLegend: React.FC = ({ const syncPaneItems = useCallback(() => { if (!containerElRef.current?.isConnected) return; + const nextItems = buildPaneItems(manager, indicators); setPaneLayouts(manager.getPaneLayouts()); - setPaneItems(buildPaneItems(manager, indicators)); + setPaneItems(nextItems); setLayoutEpoch(e => e + 1); }, [indicators, manager]); @@ -434,15 +391,9 @@ const PaneLegend: React.FC = ({ retryRef.current.forEach(clearTimeout); retryRef.current = [ setTimeout(syncPaneItems, 0), - setTimeout(syncPaneItems, 16), setTimeout(syncPaneItems, 50), - setTimeout(syncPaneItems, 120), - setTimeout(syncPaneItems, 250), + setTimeout(syncPaneItems, 200), setTimeout(syncPaneItems, 500), - setTimeout(syncPaneItems, 900), - setTimeout(syncPaneItems, 1200), - setTimeout(syncPaneItems, 1800), - setTimeout(syncPaneItems, 2500), ]; requestAnimationFrame(() => { requestAnimationFrame(syncPaneItems); @@ -450,10 +401,14 @@ const PaneLegend: React.FC = ({ }, [syncPaneItems]); useEffect(() => { + if (paused) { + setPaneItems([]); + return; + } setPaneItems([]); scheduleRetry(); return () => retryRef.current.forEach(clearTimeout); - }, [scheduleRetry, indicators]); + }, [scheduleRetry, indicators, paused]); useEffect(() => { const unsub = manager.subscribePaneLayout(() => scheduleRetry()); @@ -485,14 +440,14 @@ const PaneLegend: React.FC = ({ if (!items.length) return null; const rect = containerElRef.current.getBoundingClientRect(); if (idx <= 0) { - const pl = resolvePaneItemLayout(manager, items[0], layouts); + const pl = resolvePaneItemLayout(manager, items[0]); return pl ? rect.top + pl.topY : null; } if (idx >= items.length) { - const pl = resolvePaneItemLayout(manager, items[items.length - 1], layouts); + const pl = resolvePaneItemLayout(manager, items[items.length - 1]); return pl ? rect.top + pl.topY + pl.height : null; } - const pl = resolvePaneItemLayout(manager, items[idx - 1], layouts); + const pl = resolvePaneItemLayout(manager, items[idx - 1]); return pl ? rect.top + pl.topY + pl.height : null; }, [manager]); @@ -513,7 +468,7 @@ const PaneLegend: React.FC = ({ const fromIndex = paneItemsRef.current.findIndex(i => i.id === id); if (!item || fromIndex === -1) return; - const layout = resolvePaneItemLayout(manager, item, paneLayoutsRef.current); + const layout = resolvePaneItemLayout(manager, item); const paneCapture = layout ? capturePaneRegion(containerElRef.current, layout.topY, layout.height) : null; @@ -588,7 +543,7 @@ const PaneLegend: React.FC = ({ for (const item of items) { if (getPaneHostId(item.id, inds) === dragHost) continue; - const l = resolvePaneItemLayout(manager, item, layouts); + const l = resolvePaneItemLayout(manager, item); if (!l || mouseY < l.topY || mouseY >= l.topY + l.height) continue; const rel = (mouseY - l.topY) / Math.max(l.height, 1); if (rel > 0.28 && rel < 0.72) { @@ -600,7 +555,7 @@ const PaneLegend: React.FC = ({ if (dropMode === 'reorder') { for (let i = 0; i < items.length; i++) { - const l = resolvePaneItemLayout(manager, items[i], layouts); + const l = resolvePaneItemLayout(manager, items[i]); if (l && mouseY < l.topY + l.height / 2) { drop = i; break; @@ -645,25 +600,25 @@ const PaneLegend: React.FC = ({ : -1; const draggingLayout = draggingItemIdx >= 0 - ? resolvePaneItemLayout(manager, paneItems[draggingItemIdx], paneLayouts) + ? resolvePaneItemLayout(manager, paneItems[draggingItemIdx]) : null; const mergeTargetLayout = isDragging && dragState?.mergeTargetId ? (() => { const t = paneItems.find(i => i.id === dragState.mergeTargetId); - return t ? resolvePaneItemLayout(manager, t, paneLayouts) : null; + return t ? resolvePaneItemLayout(manager, t) : null; })() : null; - return ( - <> + if (paused || !portalHost) return null; + + const layer = ( +
{paneItems.map((item, itemIdx) => { - const pl = resolvePaneItemLayout(manager, item, paneLayouts); + const pl = resolvePaneItemLayout(manager, item); if (!pl) return null; - const screenX = containerRect.left; - const screenY = containerRect.top + pl.topY; - const paneBottomY = containerRect.top + pl.topY + pl.height; + 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); @@ -673,7 +628,9 @@ const PaneLegend: React.FC = ({ const isMergedPane = samePaneItems.length > 1; const showSplitBtn = isMergedPane && item.id === itemHostId && !!onSplit; const PANE_BTN_STEP = 24; - const paneBtnRight = (slot: number) => containerRect.right - 28 - slot * PANE_BTN_STEP; + 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; @@ -690,11 +647,7 @@ const PaneLegend: React.FC = ({ draggingLayout.height, ) : 0; - const legendTop = screenY + 5 + stackIdx * 22; - // 드래그 중 shift 전환은 transform, 드래그 후 위치 재정렬은 top - const shiftTrans = isDragging - ? 'transform 0.18s ease' - : 'top 0.22s ease, height 0.22s ease, transform 0.18s ease'; + const shiftTrans = isDragging ? 'transform 0.18s ease' : undefined; return ( @@ -702,10 +655,10 @@ const PaneLegend: React.FC = ({
= ({ : bgColor, zIndex: 480, pointerEvents: 'none', - transform: `translateY(${shiftY}px)`, - transition: shiftTrans + ', background 0.12s ease', + transform: shiftY ? `translateY(${shiftY}px)` : undefined, + transition: shiftTrans, }} /> @@ -723,22 +676,22 @@ const PaneLegend: React.FC = ({
!isDragging && onHoverName(item.id, screenX, screenY)} + onMouseEnter={() => !isDragging && onHoverName(item.id, hoverScreenX, hoverScreenY)} onMouseLeave={() => !isDragging && onLeaveName()} onDoubleClick={() => !isDragging && onDoubleClick?.(item.id)} > @@ -757,13 +710,14 @@ const PaneLegend: React.FC = ({