실시간 차트 보조지표명 겹침오류 수정

This commit is contained in:
Macbook
2026-05-29 01:59:55 +09:00
parent 3db7e85e2c
commit db1c22d217
4 changed files with 302 additions and 142 deletions
+96 -136
View File
@@ -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<number>();
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<string>();
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<PaneLegendProps> = ({
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<PaneLegendProps> = ({
const onSplitRef = useRef(onSplit);
const onDuplicateRef = useRef(onDuplicate);
const indicatorsRef = useRef(indicators);
const [portalHost, setPortalHost] = useState<HTMLElement | null>(null);
/** React portal 전용 호스트 — chart-container 직접 조작·removeChild 경합 방지 */
useEffect(() => {
const sel = ':scope > .pane-legend-portal-host';
let host = containerEl.querySelector<HTMLElement>(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<PaneLegendProps> = ({
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<PaneLegendProps> = ({
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<PaneLegendProps> = ({
}, [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<PaneLegendProps> = ({
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<PaneLegendProps> = ({
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<PaneLegendProps> = ({
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<PaneLegendProps> = ({
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<PaneLegendProps> = ({
: -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 = (
<div className="pane-legend-layer" aria-hidden={paneItems.length === 0}>
{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<PaneLegendProps> = ({
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<PaneLegendProps> = ({
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 (
<React.Fragment key={item.id}>
@@ -702,10 +655,10 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
<div
className="pane-bg-overlay"
style={{
position: 'fixed',
left: screenX,
top: screenY,
width: containerRect.width,
position: 'absolute',
left: 0,
top: pl.topY,
width: '100%',
height: pl.height,
background: isBeingDragged
? 'transparent'
@@ -714,8 +667,8 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
: 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<PaneLegendProps> = ({
<div
className="pane-legend-item"
style={{
position: 'fixed',
left: screenX + 6,
top: legendTop,
position: 'absolute',
left: 6,
top: pl.topY + 5 + stackIdx * 22,
zIndex: 502,
pointerEvents: isDragging ? 'none' : 'auto',
display: 'flex',
alignItems: 'center',
gap: '4px',
opacity: isBeingDragged ? 0 : 1,
transform: `translateY(${shiftY}px)`,
transition: shiftTrans + ', opacity 0.12s ease',
transform: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: shiftTrans,
}}
>
<span
className={`pane-legend-name${isPaneHidden ? ' pane-legend-name--hidden' : ''}`}
onMouseEnter={() => !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<PaneLegendProps> = ({
<button
className="pane-btn pane-btn-split"
style={{
position: 'fixed',
left: paneBtnRight(splitSlot),
top: screenY + 5,
position: 'absolute',
right: paneBtnRight(splitSlot),
top: pl.topY + 5,
zIndex: 505,
opacity: isBeingDragged ? 0 : undefined,
transform: `translateY(${shiftY}px)`,
transform: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: shiftTrans,
pointerEvents: 'auto',
}}
onClick={e => {
e.stopPropagation();
@@ -778,13 +732,14 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
<button
className="pane-btn pane-btn-copy"
style={{
position: 'fixed',
left: paneBtnRight(copySlot),
top: screenY + 5,
position: 'absolute',
right: paneBtnRight(copySlot),
top: pl.topY + 5,
zIndex: 505,
opacity: isBeingDragged ? 0 : undefined,
transform: `translateY(${shiftY}px)`,
transform: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: shiftTrans,
pointerEvents: 'auto',
}}
onClick={e => {
e.stopPropagation();
@@ -799,13 +754,14 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
<button
className="pane-btn pane-btn-close"
style={{
position: 'fixed',
left: paneBtnRight(closeSlot),
top: screenY + 5,
position: 'absolute',
right: paneBtnRight(closeSlot),
top: pl.topY + 5,
zIndex: 504,
opacity: isBeingDragged ? 0 : undefined,
transform: `translateY(${shiftY}px)`,
transform: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: shiftTrans,
pointerEvents: 'auto',
}}
onClick={e => { e.stopPropagation(); onRemove(item.id); }}
title="지표 제거"
@@ -820,13 +776,14 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
<div
className="pane-drag-handle"
style={{
position: 'fixed',
left: screenX + 4,
position: 'absolute',
left: 4,
top: paneBottomY - 30,
zIndex: 510,
opacity: isBeingDragged ? 0 : undefined,
transform: `translateY(${shiftY}px)`,
transition: isDragging ? 'transform 0.18s ease' : 'top 0.22s ease, transform 0.18s ease',
transform: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: shiftTrans,
pointerEvents: 'auto',
}}
onPointerDown={e => handleDragHandlePointerDown(e, item.id)}
onMouseEnter={onLeaveName}
@@ -848,13 +805,14 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
<button
className={`pane-btn pane-btn-expand${isFocused ? ' active' : ''}`}
style={{
position: 'fixed',
left: screenX + 4,
position: 'absolute',
left: 4,
top: paneBottomY - 56,
zIndex: 504,
opacity: isBeingDragged ? 0 : undefined,
transform: `translateY(${shiftY}px)`,
transform: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: shiftTrans,
pointerEvents: 'auto',
}}
onClick={e => {
e.stopPropagation();
@@ -879,8 +837,8 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
<>
{/* ① 전체 지표 영역 dim — 고스트·shift 가 더 선명하게 보이도록 */}
{paneItems.length > 0 && (() => {
const firstL = resolvePaneItemLayout(manager, paneItems[0], paneLayouts);
const lastL = resolvePaneItemLayout(manager, paneItems[paneItems.length - 1], paneLayouts);
const firstL = resolvePaneItemLayout(manager, paneItems[0]);
const lastL = resolvePaneItemLayout(manager, paneItems[paneItems.length - 1]);
if (!firstL || !lastL) return null;
return (
<div style={{
@@ -996,8 +954,10 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
</>
);
})()}
</>
</div>
);
return createPortal(layer, portalHost);
};
function formatVal(v: number): string {