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

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
+23
View File
@@ -914,10 +914,33 @@ html.theme-blue {
- height: 100% : 기본 상태에서 래퍼를 가득 채움 - height: 100% : 기본 상태에서 래퍼를 가득 채움
- JS 에서 스크롤 필요 명시적 height (e.g. 900px) 전환 */ - JS 에서 스크롤 필요 명시적 height (e.g. 900px) 전환 */
.chart-container { .chart-container {
position: relative;
width: 100%; width: 100%;
height: 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 하단 시간축 (거래량·보조지표가 아래에 있을 때) */ /* 캔들 pane 하단 시간축 (거래량·보조지표가 아래에 있을 때) */
.candle-pane-time-axis { .candle-pane-time-axis {
position: absolute; position: absolute;
+96 -136
View File
@@ -2,6 +2,7 @@
* PaneLegend * PaneLegend
* 각 서브 pane 좌측 상단에 인디케이터 명칭 + 현재 크로스헤어 값 표시. * 각 서브 pane 좌측 상단에 인디케이터 명칭 + 현재 크로스헤어 값 표시.
* 드래그 핸들(⠿) / 전체화면 / 닫기 버튼 포함. * 드래그 핸들(⠿) / 전체화면 / 닫기 버튼 포함.
* 레이아웃 갱신 시 top transition 을 쓰지 않음 — 설정 변경 직후 라벨 이중 표시 방지.
* *
* ─── 패인 인덱스 구조 ─────────────────────────────────────────────────────── * ─── 패인 인덱스 구조 ───────────────────────────────────────────────────────
* Pane 0 : 메인 캔들차트 * Pane 0 : 메인 캔들차트
@@ -10,6 +11,7 @@
* ──────────────────────────────────────────────────────────────────────────── * ────────────────────────────────────────────────────────────────────────────
*/ */
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import type { MouseEventParams, Time } from 'lightweight-charts'; import type { MouseEventParams, Time } from 'lightweight-charts';
import type { IndicatorConfig } from '../types'; import type { IndicatorConfig } from '../types';
import { ChartManager } from '../utils/ChartManager'; import { ChartManager } from '../utils/ChartManager';
@@ -56,6 +58,8 @@ export interface PaneLegendProps {
manager: ChartManager; manager: ChartManager;
indicators: IndicatorConfig[]; indicators: IndicatorConfig[];
containerEl: HTMLElement; containerEl: HTMLElement;
/** 지표 파라미터 동기화 중 — 잘못된 좌표로 라벨이 겹쳐 보이는 것 방지 */
paused?: boolean;
onHoverName: (id: string, sx: number, sy: number) => void; onHoverName: (id: string, sx: number, sy: number) => void;
onLeaveName: () => void; onLeaveName: () => void;
onDoubleClick?: (id: string) => void; onDoubleClick?: (id: string) => void;
@@ -179,16 +183,6 @@ function makeLabel(config: IndicatorConfig): string {
return nums.length ? `${name} ${nums.join(', ')}` : name; 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( function applyPaneLayoutToItem(
item: PaneItem, item: PaneItem,
layout: { topY: number; height: number }, layout: { topY: number; height: number },
@@ -199,43 +193,6 @@ function applyPaneLayoutToItem(
item.layoutHeight = layout.height; 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( function buildPaneItems(
manager: ChartManager, manager: ChartManager,
indicators: IndicatorConfig[], indicators: IndicatorConfig[],
@@ -249,11 +206,15 @@ function buildPaneItems(
.map(ind => ind.id), .map(ind => ind.id),
); );
const configById = new Map(indicators.map(ind => [ind.id, ind]));
// ChartManager 에 실제 로드된 지표만 — React state 만 있고 pane 미부착인 항목 제외 // ChartManager 에 실제 로드된 지표만 — React state 만 있고 pane 미부착인 항목 제외
const items: PaneItem[] = []; const items: PaneItem[] = [];
const seenIds = new Set<string>();
for (const info of manager.getIndicatorPaneInfo()) { for (const info of manager.getIndicatorPaneInfo()) {
if (!wantedIds.has(info.id)) continue; if (!wantedIds.has(info.id) || seenIds.has(info.id)) continue;
const ind = info.config; seenIds.add(info.id);
const ind = configById.get(info.id) ?? info.config;
const def = getIndicatorDef(ind.type); const def = getIndicatorDef(ind.type);
if (!def || def.overlay || def.returnsMarkers) continue; if (!def || def.overlay || def.returnsMarkers) continue;
@@ -276,16 +237,11 @@ function buildPaneItems(
items.sort((a, b) => a.paneIndex - b.paneIndex); items.sort((a, b) => a.paneIndex - b.paneIndex);
const subLayouts = manager.getIndicatorSubPaneLayouts();
for (const item of items) { for (const item of items) {
const lay = manager.getIndicatorPaneScreenLayout(item.id); const lay = manager.getIndicatorPaneScreenLayout(item.id);
if (lay) applyPaneLayoutToItem(item, lay, true); if (lay) applyPaneLayoutToItem(item, lay, true);
} }
fillMissingLayoutsByPaneIndex(items, subLayouts);
fillMissingLayoutsFromActiveSubPanes(items, subLayouts);
items.sort((a, b) => { items.sort((a, b) => {
const ay = a.layoutTopY ?? 1e9; const ay = a.layoutTopY ?? 1e9;
const by = b.layoutTopY ?? 1e9; const by = b.layoutTopY ?? 1e9;
@@ -293,31 +249,17 @@ function buildPaneItems(
return a.paneIndex - b.paneIndex; return a.paneIndex - b.paneIndex;
}); });
return items; return items.filter(
} item => item.layoutTopY != null && (item.layoutHeight ?? 0) > MIN_SUB_PANE_HEIGHT,
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 l ? { topY: l.topY, height: l.height } : null;
} }
/** 렌더 시점 pane 좌표 — resetPaneHeights 후 캐시 topY 가 어긋나지 않도록 시리즈 DOM 우선 */ /** 렌더 시점 pane 좌표 — 시리즈 DOM 기준만 사용 (stale paneIndex 폴백 제거) */
function resolvePaneItemLayout( function resolvePaneItemLayout(
manager: ChartManager, manager: ChartManager,
item: PaneItem, item: PaneItem,
layouts: Array<{ paneIndex: number; topY: number; height: number }>,
): { topY: number; height: number } | null { ): { topY: number; height: number } | null {
const live = manager.getIndicatorPaneScreenLayout(item.id); return manager.getIndicatorPaneScreenLayout(item.id);
if (live) return live;
return paneItemLayout(item, layouts);
} }
// ── 아이콘 SVG ──────────────────────────────────────────────────────────────── // ── 아이콘 SVG ────────────────────────────────────────────────────────────────
@@ -383,7 +325,7 @@ const IconClose = () => (
// ── 컴포넌트 ────────────────────────────────────────────────────────────────── // ── 컴포넌트 ──────────────────────────────────────────────────────────────────
const PaneLegend: React.FC<PaneLegendProps> = ({ const PaneLegend: React.FC<PaneLegendProps> = ({
manager, indicators, containerEl, manager, indicators, containerEl, paused = false,
onHoverName, onLeaveName, onDoubleClick, onHoverName, onLeaveName, onDoubleClick,
onReorder, onMerge, onSplit, onExpand, onRemove, onDuplicate, focusedId, onRestore, onReorder, onMerge, onSplit, onExpand, onRemove, onDuplicate, focusedId, onRestore,
}) => { }) => {
@@ -408,6 +350,20 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
const onSplitRef = useRef(onSplit); const onSplitRef = useRef(onSplit);
const onDuplicateRef = useRef(onDuplicate); const onDuplicateRef = useRef(onDuplicate);
const indicatorsRef = useRef(indicators); 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(() => { paneItemsRef.current = paneItems; }, [paneItems]);
useEffect(() => { paneLayoutsRef.current = paneLayouts; }, [paneLayouts]); useEffect(() => { paneLayoutsRef.current = paneLayouts; }, [paneLayouts]);
@@ -420,8 +376,9 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
const syncPaneItems = useCallback(() => { const syncPaneItems = useCallback(() => {
if (!containerElRef.current?.isConnected) return; if (!containerElRef.current?.isConnected) return;
const nextItems = buildPaneItems(manager, indicators);
setPaneLayouts(manager.getPaneLayouts()); setPaneLayouts(manager.getPaneLayouts());
setPaneItems(buildPaneItems(manager, indicators)); setPaneItems(nextItems);
setLayoutEpoch(e => e + 1); setLayoutEpoch(e => e + 1);
}, [indicators, manager]); }, [indicators, manager]);
@@ -434,15 +391,9 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
retryRef.current.forEach(clearTimeout); retryRef.current.forEach(clearTimeout);
retryRef.current = [ retryRef.current = [
setTimeout(syncPaneItems, 0), setTimeout(syncPaneItems, 0),
setTimeout(syncPaneItems, 16),
setTimeout(syncPaneItems, 50), setTimeout(syncPaneItems, 50),
setTimeout(syncPaneItems, 120), setTimeout(syncPaneItems, 200),
setTimeout(syncPaneItems, 250),
setTimeout(syncPaneItems, 500), setTimeout(syncPaneItems, 500),
setTimeout(syncPaneItems, 900),
setTimeout(syncPaneItems, 1200),
setTimeout(syncPaneItems, 1800),
setTimeout(syncPaneItems, 2500),
]; ];
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(syncPaneItems); requestAnimationFrame(syncPaneItems);
@@ -450,10 +401,14 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
}, [syncPaneItems]); }, [syncPaneItems]);
useEffect(() => { useEffect(() => {
if (paused) {
setPaneItems([]);
return;
}
setPaneItems([]); setPaneItems([]);
scheduleRetry(); scheduleRetry();
return () => retryRef.current.forEach(clearTimeout); return () => retryRef.current.forEach(clearTimeout);
}, [scheduleRetry, indicators]); }, [scheduleRetry, indicators, paused]);
useEffect(() => { useEffect(() => {
const unsub = manager.subscribePaneLayout(() => scheduleRetry()); const unsub = manager.subscribePaneLayout(() => scheduleRetry());
@@ -485,14 +440,14 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
if (!items.length) return null; if (!items.length) return null;
const rect = containerElRef.current.getBoundingClientRect(); const rect = containerElRef.current.getBoundingClientRect();
if (idx <= 0) { if (idx <= 0) {
const pl = resolvePaneItemLayout(manager, items[0], layouts); const pl = resolvePaneItemLayout(manager, items[0]);
return pl ? rect.top + pl.topY : null; return pl ? rect.top + pl.topY : null;
} }
if (idx >= items.length) { 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; 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; return pl ? rect.top + pl.topY + pl.height : null;
}, [manager]); }, [manager]);
@@ -513,7 +468,7 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
const fromIndex = paneItemsRef.current.findIndex(i => i.id === id); const fromIndex = paneItemsRef.current.findIndex(i => i.id === id);
if (!item || fromIndex === -1) return; if (!item || fromIndex === -1) return;
const layout = resolvePaneItemLayout(manager, item, paneLayoutsRef.current); const layout = resolvePaneItemLayout(manager, item);
const paneCapture = layout const paneCapture = layout
? capturePaneRegion(containerElRef.current, layout.topY, layout.height) ? capturePaneRegion(containerElRef.current, layout.topY, layout.height)
: null; : null;
@@ -588,7 +543,7 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
for (const item of items) { for (const item of items) {
if (getPaneHostId(item.id, inds) === dragHost) continue; 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; if (!l || mouseY < l.topY || mouseY >= l.topY + l.height) continue;
const rel = (mouseY - l.topY) / Math.max(l.height, 1); const rel = (mouseY - l.topY) / Math.max(l.height, 1);
if (rel > 0.28 && rel < 0.72) { if (rel > 0.28 && rel < 0.72) {
@@ -600,7 +555,7 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
if (dropMode === 'reorder') { if (dropMode === 'reorder') {
for (let i = 0; i < items.length; i++) { 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) { if (l && mouseY < l.topY + l.height / 2) {
drop = i; drop = i;
break; break;
@@ -645,25 +600,25 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
: -1; : -1;
const draggingLayout = draggingItemIdx >= 0 const draggingLayout = draggingItemIdx >= 0
? resolvePaneItemLayout(manager, paneItems[draggingItemIdx], paneLayouts) ? resolvePaneItemLayout(manager, paneItems[draggingItemIdx])
: null; : null;
const mergeTargetLayout = isDragging && dragState?.mergeTargetId const mergeTargetLayout = isDragging && dragState?.mergeTargetId
? (() => { ? (() => {
const t = paneItems.find(i => i.id === 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; : null;
return ( if (paused || !portalHost) return null;
<>
const layer = (
<div className="pane-legend-layer" aria-hidden={paneItems.length === 0}>
{paneItems.map((item, itemIdx) => { {paneItems.map((item, itemIdx) => {
const pl = resolvePaneItemLayout(manager, item, paneLayouts); const pl = resolvePaneItemLayout(manager, item);
if (!pl) return null; if (!pl) return null;
const screenX = containerRect.left; const paneBottomY = pl.topY + pl.height;
const screenY = containerRect.top + pl.topY;
const paneBottomY = containerRect.top + pl.topY + pl.height;
const itemVals = item.hidden ? [] : (values[item.id] ?? []); const itemVals = item.hidden ? [] : (values[item.id] ?? []);
const itemHostId = getPaneHostId(item.id, indicators); const itemHostId = getPaneHostId(item.id, indicators);
const samePaneItems = paneItems.filter(p => p.paneIndex === item.paneIndex); const samePaneItems = paneItems.filter(p => p.paneIndex === item.paneIndex);
@@ -673,7 +628,9 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
const isMergedPane = samePaneItems.length > 1; const isMergedPane = samePaneItems.length > 1;
const showSplitBtn = isMergedPane && item.id === itemHostId && !!onSplit; const showSplitBtn = isMergedPane && item.id === itemHostId && !!onSplit;
const PANE_BTN_STEP = 24; 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; let btnSlot = 0;
const closeSlot = btnSlot++; const closeSlot = btnSlot++;
const copySlot = onDuplicate ? btnSlot++ : -1; const copySlot = onDuplicate ? btnSlot++ : -1;
@@ -690,11 +647,7 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
draggingLayout.height, draggingLayout.height,
) )
: 0; : 0;
const legendTop = screenY + 5 + stackIdx * 22; const shiftTrans = isDragging ? 'transform 0.18s ease' : undefined;
// 드래그 중 shift 전환은 transform, 드래그 후 위치 재정렬은 top
const shiftTrans = isDragging
? 'transform 0.18s ease'
: 'top 0.22s ease, height 0.22s ease, transform 0.18s ease';
return ( return (
<React.Fragment key={item.id}> <React.Fragment key={item.id}>
@@ -702,10 +655,10 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
<div <div
className="pane-bg-overlay" className="pane-bg-overlay"
style={{ style={{
position: 'fixed', position: 'absolute',
left: screenX, left: 0,
top: screenY, top: pl.topY,
width: containerRect.width, width: '100%',
height: pl.height, height: pl.height,
background: isBeingDragged background: isBeingDragged
? 'transparent' ? 'transparent'
@@ -714,8 +667,8 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
: bgColor, : bgColor,
zIndex: 480, zIndex: 480,
pointerEvents: 'none', pointerEvents: 'none',
transform: `translateY(${shiftY}px)`, transform: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: shiftTrans + ', background 0.12s ease', transition: shiftTrans,
}} }}
/> />
@@ -723,22 +676,22 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
<div <div
className="pane-legend-item" className="pane-legend-item"
style={{ style={{
position: 'fixed', position: 'absolute',
left: screenX + 6, left: 6,
top: legendTop, top: pl.topY + 5 + stackIdx * 22,
zIndex: 502, zIndex: 502,
pointerEvents: isDragging ? 'none' : 'auto', pointerEvents: isDragging ? 'none' : 'auto',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '4px', gap: '4px',
opacity: isBeingDragged ? 0 : 1, opacity: isBeingDragged ? 0 : 1,
transform: `translateY(${shiftY}px)`, transform: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: shiftTrans + ', opacity 0.12s ease', transition: shiftTrans,
}} }}
> >
<span <span
className={`pane-legend-name${isPaneHidden ? ' pane-legend-name--hidden' : ''}`} 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()} onMouseLeave={() => !isDragging && onLeaveName()}
onDoubleClick={() => !isDragging && onDoubleClick?.(item.id)} onDoubleClick={() => !isDragging && onDoubleClick?.(item.id)}
> >
@@ -757,13 +710,14 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
<button <button
className="pane-btn pane-btn-split" className="pane-btn pane-btn-split"
style={{ style={{
position: 'fixed', position: 'absolute',
left: paneBtnRight(splitSlot), right: paneBtnRight(splitSlot),
top: screenY + 5, top: pl.topY + 5,
zIndex: 505, zIndex: 505,
opacity: isBeingDragged ? 0 : undefined, opacity: isBeingDragged ? 0 : undefined,
transform: `translateY(${shiftY}px)`, transform: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: shiftTrans, transition: shiftTrans,
pointerEvents: 'auto',
}} }}
onClick={e => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
@@ -778,13 +732,14 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
<button <button
className="pane-btn pane-btn-copy" className="pane-btn pane-btn-copy"
style={{ style={{
position: 'fixed', position: 'absolute',
left: paneBtnRight(copySlot), right: paneBtnRight(copySlot),
top: screenY + 5, top: pl.topY + 5,
zIndex: 505, zIndex: 505,
opacity: isBeingDragged ? 0 : undefined, opacity: isBeingDragged ? 0 : undefined,
transform: `translateY(${shiftY}px)`, transform: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: shiftTrans, transition: shiftTrans,
pointerEvents: 'auto',
}} }}
onClick={e => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
@@ -799,13 +754,14 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
<button <button
className="pane-btn pane-btn-close" className="pane-btn pane-btn-close"
style={{ style={{
position: 'fixed', position: 'absolute',
left: paneBtnRight(closeSlot), right: paneBtnRight(closeSlot),
top: screenY + 5, top: pl.topY + 5,
zIndex: 504, zIndex: 504,
opacity: isBeingDragged ? 0 : undefined, opacity: isBeingDragged ? 0 : undefined,
transform: `translateY(${shiftY}px)`, transform: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: shiftTrans, transition: shiftTrans,
pointerEvents: 'auto',
}} }}
onClick={e => { e.stopPropagation(); onRemove(item.id); }} onClick={e => { e.stopPropagation(); onRemove(item.id); }}
title="지표 제거" title="지표 제거"
@@ -820,13 +776,14 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
<div <div
className="pane-drag-handle" className="pane-drag-handle"
style={{ style={{
position: 'fixed', position: 'absolute',
left: screenX + 4, left: 4,
top: paneBottomY - 30, top: paneBottomY - 30,
zIndex: 510, zIndex: 510,
opacity: isBeingDragged ? 0 : undefined, opacity: isBeingDragged ? 0 : undefined,
transform: `translateY(${shiftY}px)`, transform: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: isDragging ? 'transform 0.18s ease' : 'top 0.22s ease, transform 0.18s ease', transition: shiftTrans,
pointerEvents: 'auto',
}} }}
onPointerDown={e => handleDragHandlePointerDown(e, item.id)} onPointerDown={e => handleDragHandlePointerDown(e, item.id)}
onMouseEnter={onLeaveName} onMouseEnter={onLeaveName}
@@ -848,13 +805,14 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
<button <button
className={`pane-btn pane-btn-expand${isFocused ? ' active' : ''}`} className={`pane-btn pane-btn-expand${isFocused ? ' active' : ''}`}
style={{ style={{
position: 'fixed', position: 'absolute',
left: screenX + 4, left: 4,
top: paneBottomY - 56, top: paneBottomY - 56,
zIndex: 504, zIndex: 504,
opacity: isBeingDragged ? 0 : undefined, opacity: isBeingDragged ? 0 : undefined,
transform: `translateY(${shiftY}px)`, transform: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: shiftTrans, transition: shiftTrans,
pointerEvents: 'auto',
}} }}
onClick={e => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
@@ -879,8 +837,8 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
<> <>
{/* ① 전체 지표 영역 dim — 고스트·shift 가 더 선명하게 보이도록 */} {/* ① 전체 지표 영역 dim — 고스트·shift 가 더 선명하게 보이도록 */}
{paneItems.length > 0 && (() => { {paneItems.length > 0 && (() => {
const firstL = resolvePaneItemLayout(manager, paneItems[0], paneLayouts); const firstL = resolvePaneItemLayout(manager, paneItems[0]);
const lastL = resolvePaneItemLayout(manager, paneItems[paneItems.length - 1], paneLayouts); const lastL = resolvePaneItemLayout(manager, paneItems[paneItems.length - 1]);
if (!firstL || !lastL) return null; if (!firstL || !lastL) return null;
return ( return (
<div style={{ <div style={{
@@ -996,8 +954,10 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
</> </>
); );
})()} })()}
</> </div>
); );
return createPortal(layer, portalHost);
}; };
function formatVal(v: number): string { function formatVal(v: number): string {
+10 -3
View File
@@ -221,6 +221,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
const prevSortedPKRef = useRef<string>(''); // 순서 무관 paramKey (reorder 감지용) const prevSortedPKRef = useRef<string>(''); // 순서 무관 paramKey (reorder 감지용)
const prevIndicatorsListRef = useRef<IndicatorConfig[]>(indicators); const prevIndicatorsListRef = useRef<IndicatorConfig[]>(indicators);
const indicatorSyncInFlightRef = useRef(false); const indicatorSyncInFlightRef = useRef(false);
const [paneLegendPaused, setPaneLegendPaused] = useState(false);
const pendingIndicatorResyncRef = useRef(false); const pendingIndicatorResyncRef = useRef(false);
const indicatorReloadGenRef = useRef(0); const indicatorReloadGenRef = useRef(0);
const prevMarket = useRef<string>(market); const prevMarket = useRef<string>(market);
@@ -1176,15 +1177,19 @@ const TradingChart: React.FC<TradingChartProps> = ({
const savedLR = mgr.getVisibleLogicalRange(); const savedLR = mgr.getVisibleLogicalRange();
const syncGen = ++indicatorReloadGenRef.current; const syncGen = ++indicatorReloadGenRef.current;
indicatorSyncInFlightRef.current = true; indicatorSyncInFlightRef.current = true;
void mgr.refreshIndicators(paramsChangedConfigs).then(() => { setPaneLegendPaused(true);
void mgr.refreshIndicatorParamsInPlace(paramsChangedConfigs).then(() => {
if (syncGen !== indicatorReloadGenRef.current) { if (syncGen !== indicatorReloadGenRef.current) {
indicatorSyncInFlightRef.current = false; indicatorSyncInFlightRef.current = false;
setPaneLegendPaused(false);
return; return;
} }
afterIndicatorPaneMutation(mgr); afterIndicatorPaneMutation(mgr);
mgr.notifyPaneLayoutChanged();
restoreLogicalRange(mgr, savedLR); restoreLogicalRange(mgr, savedLR);
syncIndicatorTrackingRefs(indicators); syncIndicatorTrackingRefs(indicators);
indicatorSyncInFlightRef.current = false; indicatorSyncInFlightRef.current = false;
setPaneLegendPaused(false);
flushPendingIndicatorResync(mgr, syncGen); flushPendingIndicatorResync(mgr, syncGen);
}); });
} else if (isReorderOnly) { } else if (isReorderOnly) {
@@ -1401,8 +1406,10 @@ const TradingChart: React.FC<TradingChartProps> = ({
)} )}
{chartMgr && !candleOnlyMode && showPaneLegend && ( {chartMgr && !candleOnlyMode && showPaneLegend && (
<PaneLegendPortal <PaneLegendPortal
key={indKey(indicators)}
manager={chartMgr} manager={chartMgr}
indicators={indicators} indicators={indicators}
paused={paneLegendPaused}
getContainer={() => containerRef.current} getContainer={() => containerRef.current}
onHoverName={(id, sx, sy) => onHoverPaneLegend?.(id, sx, sy)} onHoverName={(id, sx, sy) => onHoverPaneLegend?.(id, sx, sy)}
onLeaveName={() => onLeavePaneLegend?.()} onLeaveName={() => onLeavePaneLegend?.()}
@@ -1487,7 +1494,7 @@ const CandlePaneControlsPortal: React.FC<{
// ── containerEl 을 안전하게 주입하는 래퍼 ─────────────────────────────────── // ── containerEl 을 안전하게 주입하는 래퍼 ───────────────────────────────────
const PaneLegendPortal: React.FC< const PaneLegendPortal: React.FC<
Omit<PaneLegendProps, 'containerEl'> & { getContainer: () => HTMLElement | null } Omit<PaneLegendProps, 'containerEl'> & { getContainer: () => HTMLElement | null }
> = ({ getContainer, ...rest }) => { > = ({ getContainer, paused, ...rest }) => {
const [el, setEl] = useState<HTMLElement | null>(null); const [el, setEl] = useState<HTMLElement | null>(null);
useEffect(() => { useEffect(() => {
@@ -1501,7 +1508,7 @@ const PaneLegendPortal: React.FC<
}, []); }, []);
if (!el) return null; if (!el) return null;
return <PaneLegend {...rest} containerEl={el} />; return <PaneLegend {...rest} containerEl={el} paused={paused} />;
}; };
// ── 변경 감지용 키 생성 헬퍼 ──────────────────────────────────────────────── // ── 변경 감지용 키 생성 헬퍼 ────────────────────────────────────────────────
+173 -3
View File
@@ -643,13 +643,14 @@ export class ChartManager {
} else { } else {
const isPlotVisible = !indicatorHidden && config.plotVisibility?.[plotDef.id] !== false; const isPlotVisible = !indicatorHidden && config.plotVisibility?.[plotDef.id] !== false;
const showPriceLabel = this.shouldShowSeriesPriceLabel(config); const showPriceLabel = this.shouldShowSeriesPriceLabel(config);
const showSeriesTitle = pane < 2 && showPriceLabel;
series = this.chart.addSeries(LineSeries, { series = this.chart.addSeries(LineSeries, {
color: plotDef.color, color: plotDef.color,
lineWidth: (plotDef.lineWidth ?? 1) as 1 | 2 | 3 | 4, lineWidth: (plotDef.lineWidth ?? 1) as 1 | 2 | 3 | 4,
lineStyle: plotSeriesLineStyle(plotDef.lineStyle), lineStyle: plotSeriesLineStyle(plotDef.lineStyle),
priceLineVisible: false, priceLineVisible: false,
lastValueVisible: showPriceLabel, lastValueVisible: showPriceLabel,
title: this.seriesTitleForPlot(config, plotDef), title: showSeriesTitle ? this.seriesTitleForPlot(config, plotDef) : '',
visible: isPlotVisible, visible: isPlotVisible,
}, pane); }, pane);
series.setData(filtered.map(p => ({ time: p.time as Time, value: p.value }))); series.setData(filtered.map(p => ({ time: p.time as Time, value: p.value })));
@@ -732,6 +733,7 @@ export class ChartManager {
} }
if (configs.length > 0 && this._activeIndicatorPaneIndices().size > 0) { if (configs.length > 0 && this._activeIndicatorPaneIndices().size > 0) {
this._removeOrphanSubPanes(); this._removeOrphanSubPanes();
this._resyncIndicatorPaneIndices();
this.resetPaneHeights(this._lastLayoutAvailableHeight); this.resetPaneHeights(this._lastLayoutAvailableHeight);
this._notifyPaneLayout(); this._notifyPaneLayout();
} }
@@ -828,7 +830,10 @@ export class ChartManager {
for (const c of configs) { for (const c of configs) {
if (this._detachIndicatorEntry(c.id)) any = true; if (this._detachIndicatorEntry(c.id)) any = true;
} }
if (any) this._trimTrailingEmptySubPanes(); if (any) {
this._removeOrphanSubPanes();
this._trimTrailingEmptySubPanes();
}
if (this._indicatorLoadStale(loadGen)) return; if (this._indicatorLoadStale(loadGen)) return;
for (const config of configs) { for (const config of configs) {
@@ -838,12 +843,173 @@ export class ChartManager {
if (this._indicatorLoadStale(loadGen)) return; if (this._indicatorLoadStale(loadGen)) return;
this._removeOrphanSubPanes(); this._removeOrphanSubPanes();
this._resyncIndicatorPaneIndices();
if (this._activeIndicatorPaneIndices().size > 0) { if (this._activeIndicatorPaneIndices().size > 0) {
this.resetPaneHeights(this._lastLayoutAvailableHeight); this.resetPaneHeights(this._lastLayoutAvailableHeight);
this._notifyPaneLayout(); this._notifyPaneLayout();
} }
} }
/**
* 파라미터만 변경된 지표 — pane·시리즈를 유지하고 setData 로 갱신.
* (detach/re-add 시 orphan pane·레이블 좌표 어긋남·이중 표시 방지)
*/
async refreshIndicatorParamsInPlace(configs: IndicatorConfig[]): Promise<void> {
if (configs.length === 0) return;
this.cancelPendingIndicatorUpdates();
const loadGen = ++this._dataGeneration;
const fallback: IndicatorConfig[] = [];
for (const raw of configs) {
const config = enrichIndicatorConfig(raw);
const def = getIndicatorDef(config.type);
if (def?.returnsMarkers) {
fallback.push(config);
continue;
}
const entry = this.indicators.get(config.id);
if (!entry || entry.seriesList.length === 0 || entry.type !== config.type) {
fallback.push(config);
continue;
}
try {
const result = await calculateIndicator(
config.type, this.rawBars.slice(), config.params ?? {},
);
if (this._indicatorLoadStale(loadGen)) return;
if (!this._canApplyPlotsInPlace(entry, result.plots)) {
fallback.push(config);
continue;
}
entry.config = config;
if (config.type === 'IchimokuCloud') {
this._setIchimokuPlotsFullData(entry, result.plots, config);
if (entry.cloudPlugin) {
this._applyIchimokuCloudPlugin(entry.cloudPlugin, result.plots, config);
}
} else {
this._setIndicatorPlotsFullData(entry, result.plots, config);
}
this.applyIndicatorStyle(config);
} catch (e) {
console.error(`[Indicator in-place] ${config.type}:`, e);
fallback.push(config);
}
}
if (this._indicatorLoadStale(loadGen)) return;
if (fallback.length > 0) {
await this.refreshIndicators(fallback);
return;
}
this._resyncIndicatorPaneIndices();
this._notifyPaneLayout();
}
private _canApplyPlotsInPlace(
entry: IndicatorEntry,
plots: Record<string, PlotData>,
): boolean {
if (entry.seriesMeta.length === 0) return false;
for (const meta of entry.seriesMeta) {
const data = plots[meta.plotId] as PlotData | undefined;
if (!data?.length) return false;
const filtered = data.filter(p => p.value !== null && !isNaN(p.value));
if (filtered.length === 0) return false;
}
return true;
}
/** 기존 시리즈에 전체 plot 데이터 재적용 (pane 유지) */
private _setIndicatorPlotsFullData(
entry: IndicatorEntry,
plots: Record<string, PlotData>,
config: IndicatorConfig,
): void {
const plotDefs = config.plots ?? getIndicatorDef(config.type)?.plots ?? [];
const plotById = Object.fromEntries(plotDefs.map((p: PlotDef) => [p.id, p]));
for (let i = 0; i < entry.seriesList.length; i++) {
const meta = entry.seriesMeta[i];
if (!meta) continue;
const plot = plotById[meta.plotId];
const plotData = plots[meta.plotId] as PlotData | undefined;
if (!plot || !plotData?.length) continue;
const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
if (filtered.length === 0) continue;
const series = entry.seriesList[i];
if (meta.isHistogram) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(series as ISeriesApi<any>).setData(mapHistogramSeriesData(filtered, plot));
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(series as ISeriesApi<any>).setData(
filtered.map(p => ({ time: p.time as Time, value: p.value })),
);
}
}
if (config.type === 'BollingerBands') {
detachBbBandFill(entry);
attachBbBandFill(entry, config);
entry.bbFillPrimitive?.requestRefresh();
}
}
private _setIchimokuPlotsFullData(
entry: IndicatorEntry,
plots: Record<string, PlotData>,
config: IndicatorConfig,
): void {
const { senkou: displacement } = resolveIchimokuDisplacements(config.params);
const laggingPeriod = (config.params?.laggingSpan2Periods as number) ?? 52;
const convPlot = (plots['plot0'] as PlotData) ?? [];
const basePlot = (plots['plot1'] as PlotData) ?? [];
for (let i = 0; i < entry.seriesList.length; i++) {
const meta = entry.seriesMeta[i];
if (!meta) continue;
const plotData = plots[meta.plotId] as PlotData | undefined;
if (!plotData?.length) continue;
const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
if (filtered.length === 0) continue;
let seriesData: Array<{ time: Time; value: number }> =
filtered.map(p => ({ time: p.time as Time, value: p.value }));
if (meta.plotId === 'plot3') {
seriesData = seriesData.concat(
this._ichimokuFutureSpanA(convPlot, basePlot, displacement),
);
} else if (meta.plotId === 'plot4') {
seriesData = seriesData.concat(
this._ichimokuFutureSpanB(displacement, laggingPeriod),
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(entry.seriesList[i] as ISeriesApi<any>).setData(seriesData);
}
}
/** LWC pane index ↔ entry.paneIndex 동기화 (removePane 후 어긋남 보정) */
private _resyncIndicatorPaneIndices(): void {
for (const entry of this.indicators.values()) {
const first = entry.seriesList[0];
if (!first) continue;
try {
const pi = first.getPane().paneIndex();
if (pi >= 0) entry.paneIndex = pi;
} catch { /* ok */ }
}
}
/** 메인 시리즈 마커 플러그인 해제 (setData 등 시리즈 교체 전 호출) */ /** 메인 시리즈 마커 플러그인 해제 (setData 등 시리즈 교체 전 호출) */
private _disposeMainMarkersPlugin(): void { private _disposeMainMarkersPlugin(): void {
if (!this.mainMarkersPlugin) return; if (!this.mainMarkersPlugin) return;
@@ -1993,6 +2159,7 @@ export class ChartManager {
config = enrichIndicatorConfig(config); config = enrichIndicatorConfig(config);
const entry = this.indicators.get(config.id); const entry = this.indicators.get(config.id);
if (!entry) return; if (!entry) return;
entry.config = config;
const def = getIndicatorDef(config.type); const def = getIndicatorDef(config.type);
const plotDefs = config.plots ?? def?.plots ?? []; const plotDefs = config.plots ?? def?.plots ?? [];
@@ -2016,8 +2183,11 @@ export class ChartManager {
plotAllows = entry.seriesMeta[i]?.plotId !== 'plot2'; plotAllows = entry.seriesMeta[i]?.plotId !== 'plot2';
} }
const showPriceLabel = this.shouldShowSeriesPriceLabel(config, plotAllows); const showPriceLabel = this.shouldShowSeriesPriceLabel(config, plotAllows);
const paneIdx = entry.paneIndex ?? 0;
opts['lastValueVisible'] = showPriceLabel; opts['lastValueVisible'] = showPriceLabel;
opts['title'] = this.seriesTitleForPlot(config, plot, plotAllows); opts['title'] = paneIdx < 2 && showPriceLabel
? this.seriesTitleForPlot(config, plot, plotAllows)
: '';
} else { } else {
opts['color'] = plot.color ?? '#aaa'; opts['color'] = plot.color ?? '#aaa';
} }