실시간 차트 보조지표명 겹침오류 수정
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── 변경 감지용 키 생성 헬퍼 ────────────────────────────────────────────────
|
// ── 변경 감지용 키 생성 헬퍼 ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user