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

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% : 기본 상태에서 래퍼를 가득 채움
- 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;
+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 {
+10 -3
View File
@@ -221,6 +221,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
const prevSortedPKRef = useRef<string>(''); // 순서 무관 paramKey (reorder 감지용)
const prevIndicatorsListRef = useRef<IndicatorConfig[]>(indicators);
const indicatorSyncInFlightRef = useRef(false);
const [paneLegendPaused, setPaneLegendPaused] = useState(false);
const pendingIndicatorResyncRef = useRef(false);
const indicatorReloadGenRef = useRef(0);
const prevMarket = useRef<string>(market);
@@ -1176,15 +1177,19 @@ const TradingChart: React.FC<TradingChartProps> = ({
const savedLR = mgr.getVisibleLogicalRange();
const syncGen = ++indicatorReloadGenRef.current;
indicatorSyncInFlightRef.current = true;
void mgr.refreshIndicators(paramsChangedConfigs).then(() => {
setPaneLegendPaused(true);
void mgr.refreshIndicatorParamsInPlace(paramsChangedConfigs).then(() => {
if (syncGen !== indicatorReloadGenRef.current) {
indicatorSyncInFlightRef.current = false;
setPaneLegendPaused(false);
return;
}
afterIndicatorPaneMutation(mgr);
mgr.notifyPaneLayoutChanged();
restoreLogicalRange(mgr, savedLR);
syncIndicatorTrackingRefs(indicators);
indicatorSyncInFlightRef.current = false;
setPaneLegendPaused(false);
flushPendingIndicatorResync(mgr, syncGen);
});
} else if (isReorderOnly) {
@@ -1401,8 +1406,10 @@ const TradingChart: React.FC<TradingChartProps> = ({
)}
{chartMgr && !candleOnlyMode && showPaneLegend && (
<PaneLegendPortal
key={indKey(indicators)}
manager={chartMgr}
indicators={indicators}
paused={paneLegendPaused}
getContainer={() => containerRef.current}
onHoverName={(id, sx, sy) => onHoverPaneLegend?.(id, sx, sy)}
onLeaveName={() => onLeavePaneLegend?.()}
@@ -1487,7 +1494,7 @@ const CandlePaneControlsPortal: React.FC<{
// ── containerEl 을 안전하게 주입하는 래퍼 ───────────────────────────────────
const PaneLegendPortal: React.FC<
Omit<PaneLegendProps, 'containerEl'> & { getContainer: () => HTMLElement | null }
> = ({ getContainer, ...rest }) => {
> = ({ getContainer, paused, ...rest }) => {
const [el, setEl] = useState<HTMLElement | null>(null);
useEffect(() => {
@@ -1501,7 +1508,7 @@ const PaneLegendPortal: React.FC<
}, []);
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 {
const isPlotVisible = !indicatorHidden && config.plotVisibility?.[plotDef.id] !== false;
const showPriceLabel = this.shouldShowSeriesPriceLabel(config);
const showSeriesTitle = pane < 2 && showPriceLabel;
series = this.chart.addSeries(LineSeries, {
color: plotDef.color,
lineWidth: (plotDef.lineWidth ?? 1) as 1 | 2 | 3 | 4,
lineStyle: plotSeriesLineStyle(plotDef.lineStyle),
priceLineVisible: false,
lastValueVisible: showPriceLabel,
title: this.seriesTitleForPlot(config, plotDef),
title: showSeriesTitle ? this.seriesTitleForPlot(config, plotDef) : '',
visible: isPlotVisible,
}, pane);
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) {
this._removeOrphanSubPanes();
this._resyncIndicatorPaneIndices();
this.resetPaneHeights(this._lastLayoutAvailableHeight);
this._notifyPaneLayout();
}
@@ -828,7 +830,10 @@ export class ChartManager {
for (const c of configs) {
if (this._detachIndicatorEntry(c.id)) any = true;
}
if (any) this._trimTrailingEmptySubPanes();
if (any) {
this._removeOrphanSubPanes();
this._trimTrailingEmptySubPanes();
}
if (this._indicatorLoadStale(loadGen)) return;
for (const config of configs) {
@@ -838,12 +843,173 @@ export class ChartManager {
if (this._indicatorLoadStale(loadGen)) return;
this._removeOrphanSubPanes();
this._resyncIndicatorPaneIndices();
if (this._activeIndicatorPaneIndices().size > 0) {
this.resetPaneHeights(this._lastLayoutAvailableHeight);
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 등 시리즈 교체 전 호출) */
private _disposeMainMarkersPlugin(): void {
if (!this.mainMarkersPlugin) return;
@@ -1993,6 +2159,7 @@ export class ChartManager {
config = enrichIndicatorConfig(config);
const entry = this.indicators.get(config.id);
if (!entry) return;
entry.config = config;
const def = getIndicatorDef(config.type);
const plotDefs = config.plots ?? def?.plots ?? [];
@@ -2016,8 +2183,11 @@ export class ChartManager {
plotAllows = entry.seriesMeta[i]?.plotId !== 'plot2';
}
const showPriceLabel = this.shouldShowSeriesPriceLabel(config, plotAllows);
const paneIdx = entry.paneIndex ?? 0;
opts['lastValueVisible'] = showPriceLabel;
opts['title'] = this.seriesTitleForPlot(config, plot, plotAllows);
opts['title'] = paneIdx < 2 && showPriceLabel
? this.seriesTitleForPlot(config, plot, plotAllows)
: '';
} else {
opts['color'] = plot.color ?? '#aaa';
}