보조지표 툴바추가

This commit is contained in:
Macbook
2026-05-29 23:51:13 +09:00
parent 095b0d7db7
commit 6332d198b1
11 changed files with 846 additions and 436 deletions
+15 -322
View File
@@ -15,26 +15,15 @@ import { createPortal } from 'react-dom';
import type { MouseEventParams, Time } from 'lightweight-charts';
import type { IndicatorConfig } from '../types';
import { ChartManager } from '../utils/ChartManager';
import { getIndicatorDef, getIndicatorChartTitle } from '../utils/indicatorRegistry';
import { getGlobalParamKeys, getPlotParamKeys } from '../utils/indicatorSettingsLayout';
import { formatBbLegendLabel } from '../utils/bollingerConfig';
import { bindWindowDrag, clientXYFromReact } from '../utils/pointerDrag';
import { getPaneHostId } from '../utils/indicatorPaneMerge';
import {
buildPaneItems,
resolvePaneItemLayout,
type PaneItem,
} from './paneLegendLayout';
// ── 타입 ─────────────────────────────────────────────────────────────────────
interface PaneItem {
id: string;
paneIndex: number;
label: string;
plotColors: string[];
hidden?: boolean;
/** 화면에 보이는 sub-pane 위치 (orphan pane 인덱스와 분리) */
layoutTopY?: number;
layoutHeight?: number;
}
const MIN_SUB_PANE_HEIGHT = 12;
interface PaneCapture {
dataUrl: string;
cssWidth: number;
@@ -66,18 +55,8 @@ export interface PaneLegendProps {
onReorder?: (fromId: string, insertBeforeId: string | null) => void;
/** 보조지표 pane 병합 */
onMerge?: (fromId: string, intoId: string) => void;
/** 병합 pane 분리 */
onSplit?: (hostId: string) => void;
/** 지표 단독 전체화면 확장 */
onExpand?: (id: string) => void;
/** 지표 제거 (X 버튼) */
onRemove?: (id: string) => void;
/** 보조지표 pane 복사 */
onDuplicate?: (id: string) => void;
/** 현재 단독 전체화면 중인 지표 id */
focusedId?: string | null;
/** 전체화면 → 전체 보기 복원 */
onRestore?: () => void;
/** 드래그 핸들 — ChartRightToolbar 에서 호출 */
dragHandleRef?: React.MutableRefObject<((id: string, e: React.PointerEvent) => void) | null>;
/** 크로스헤어 이동 시 보조지표 수치 표시 */
crosshairInfoVisible?: boolean;
}
@@ -159,138 +138,7 @@ function computeShiftY(
return 0;
}
function makeLabel(config: IndicatorConfig): string {
if (config.type === 'BollingerBands') {
return formatBbLegendLabel(config.params ?? {});
}
const name = getIndicatorChartTitle(config.type);
const def = getIndicatorDef(config.type);
const params = (config.params ?? def?.defaultParams ?? {}) as Record<string, number | string | boolean>;
const plots = config.plots ?? def?.plots ?? [];
const orderedKeys: string[] = [];
for (let i = 0; i < plots.length; i++) {
for (const k of getPlotParamKeys(config.type, plots[i].id, i, plots, params)) {
if (!orderedKeys.includes(k)) orderedKeys.push(k);
}
}
for (const k of getGlobalParamKeys(config.type, params, plots)) {
if (!orderedKeys.includes(k) && typeof params[k] === 'number') orderedKeys.push(k);
}
const nums = orderedKeys
.map(k => params[k])
.filter((v): v is number => typeof v === 'number')
.slice(0, 3);
return nums.length ? `${name} ${nums.join(', ')}` : name;
}
function applyPaneLayoutToItem(
item: PaneItem,
layout: { topY: number; height: number },
overwrite = false,
): void {
if (!overwrite && item.layoutTopY != null) return;
item.layoutTopY = layout.topY;
item.layoutHeight = layout.height;
}
function buildPaneItems(
manager: ChartManager,
indicators: IndicatorConfig[],
): PaneItem[] {
const wantedIds = new Set(
indicators
.filter(ind => {
const def = getIndicatorDef(ind.type);
return def && !def.overlay && !def.returnsMarkers;
})
.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) || 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;
let chartPane = info.paneIndex;
if (ind.mergedWith) {
const host = manager.getIndicatorPaneIndex(ind.mergedWith);
if (host >= 2) chartPane = host;
}
items.push({
id: info.id,
paneIndex: chartPane,
label: makeLabel(ind),
plotColors: info.plotColors.map(c => c.slice(0, 7)),
hidden: ind.hidden === true,
});
}
if (items.length === 0) return [];
items.sort((a, b) => a.paneIndex - b.paneIndex);
for (const item of items) {
const lay = manager.getIndicatorPaneScreenLayout(item.id);
if (lay) applyPaneLayoutToItem(item, lay, true);
}
items.sort((a, b) => {
const ay = a.layoutTopY ?? 1e9;
const by = b.layoutTopY ?? 1e9;
if (ay !== by) return ay - by;
return a.paneIndex - b.paneIndex;
});
return items.filter(
item => item.layoutTopY != null && (item.layoutHeight ?? 0) > MIN_SUB_PANE_HEIGHT,
);
}
/** 렌더 시점 pane 좌표 — 시리즈 DOM 기준만 사용 (stale paneIndex 폴백 제거) */
function resolvePaneItemLayout(
manager: ChartManager,
item: PaneItem,
): { topY: number; height: number } | null {
return manager.getIndicatorPaneScreenLayout(item.id);
}
// ── 아이콘 SVG ────────────────────────────────────────────────────────────────
/** ⤡ 전체화면 확장 — 네 꼭지점 바깥 방향 화살표 */
const IconExpand = () => (
<svg width="13" height="13" viewBox="0 0 14 14" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{/* 좌하단 → 좌하 */}
<polyline points="1,9 1,13 5,13"/>
<line x1="1" y1="13" x2="5.5" y2="8.5"/>
{/* 우상단 → 우상 */}
<polyline points="13,5 13,1 9,1"/>
<line x1="13" y1="1" x2="8.5" y2="5.5"/>
</svg>
);
/** ⤢ 전체화면 복원 — 네 꼭지점 안쪽 방향 화살표 */
const IconRestore = () => (
<svg width="13" height="13" viewBox="0 0 14 14" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{/* 좌하단 → 중앙 */}
<polyline points="5,13 1,13 1,9"/>
<line x1="1" y1="13" x2="5.5" y2="8.5"/>
{/* 우상단 → 중앙 */}
<polyline points="9,1 13,1 13,5"/>
<line x1="13" y1="1" x2="8.5" y2="5.5"/>
</svg>
);
const IconMerge = () => (
<svg width="28" height="28" viewBox="0 0 28 28" fill="none"
stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
@@ -299,37 +147,11 @@ const IconMerge = () => (
</svg>
);
/** 그래프 복사 — 겹친 사각형 */
const IconCopy = () => (
<svg width="13" height="13" viewBox="0 0 14 14" fill="none"
stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="4.5" y="1.5" width="8" height="8" rx="1"/>
<path d="M1.5 4.5v8h8"/>
</svg>
);
const IconSplit = () => (
<svg width="13" height="13" viewBox="0 0 14 14" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="1,7 5,3 5,11"/>
<polyline points="13,7 9,3 9,11"/>
<line x1="5" y1="7" x2="9" y2="7"/>
</svg>
);
const IconClose = () => (
<svg width="11" height="11" viewBox="0 0 11 11" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="1.5" y1="1.5" x2="9.5" y2="9.5"/>
<line x1="9.5" y1="1.5" x2="1.5" y2="9.5"/>
</svg>
);
// ── 컴포넌트 ──────────────────────────────────────────────────────────────────
const PaneLegend: React.FC<PaneLegendProps> = ({
manager, indicators, containerEl, paused = false,
onHoverName, onLeaveName, onDoubleClick,
onReorder, onMerge, onSplit, onExpand, onRemove, onDuplicate, focusedId, onRestore,
onReorder, onMerge, dragHandleRef,
crosshairInfoVisible = true,
}) => {
const [paneItems, setPaneItems] = useState<PaneItem[]>([]);
@@ -350,8 +172,6 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
const containerElRef = useRef(containerEl);
const onReorderRef = useRef(onReorder);
const onMergeRef = useRef(onMerge);
const onSplitRef = useRef(onSplit);
const onDuplicateRef = useRef(onDuplicate);
const indicatorsRef = useRef(indicators);
const [portalHost, setPortalHost] = useState<HTMLElement | null>(null);
@@ -373,8 +193,6 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
useEffect(() => { containerElRef.current = containerEl; }, [containerEl]);
useEffect(() => { onReorderRef.current = onReorder; }, [onReorder]);
useEffect(() => { onMergeRef.current = onMerge; }, [onMerge]);
useEffect(() => { onSplitRef.current = onSplit; }, [onSplit]);
useEffect(() => { onDuplicateRef.current = onDuplicate; }, [onDuplicate]);
useEffect(() => { indicatorsRef.current = indicators; }, [indicators]);
const syncPaneItems = useCallback(() => {
@@ -587,10 +405,15 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
finishDrag,
);
document.addEventListener('keydown', handleKeyDown);
}, []);
}, [manager]);
useEffect(() => {
if (!dragHandleRef) return;
dragHandleRef.current = (id, e) => handleDragHandlePointerDown(e, id);
return () => { dragHandleRef.current = null; };
}, [dragHandleRef, handleDragHandlePointerDown]);
// ── 렌더 ─────────────────────────────────────────────────────────────────
const canReorder = !!onReorder && paneItems.length > 1;
const isDragging = dragState !== null;
const dropLineY = isDragging && dragState!.dropMode === 'reorder'
? getDropLineY(dragState!.dropBeforeIndex)
@@ -625,23 +448,13 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
const pl = resolvePaneItemLayout(manager, item);
if (!pl) return null;
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);
const stackIdx = samePaneItems.findIndex(p => p.id === item.id);
const isBeingDragged = isDragging && itemHostId === draggingHostId;
const isFocused = focusedId === item.id;
const isMergedPane = samePaneItems.length > 1;
const showSplitBtn = isMergedPane && item.id === itemHostId && !!onSplit;
const PANE_BTN_STEP = 24;
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;
const splitSlot = showSplitBtn ? btnSlot++ : -1;
const isPaneHidden = item.hidden === true;
const bgColor = PANE_BG_COLORS[itemIdx % PANE_BG_COLORS.length];
@@ -711,126 +524,6 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
</span>
))}
</div>
{/* ── 우측 상단: 분리 / 복사 / 닫기 ─────────────────────── */}
{showSplitBtn && splitSlot >= 0 && (
<button
className="pane-btn pane-btn-split"
style={{
position: 'absolute',
right: paneBtnRight(splitSlot),
top: pl.topY + 5,
zIndex: 505,
opacity: isBeingDragged ? 0 : undefined,
transform: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: shiftTrans,
pointerEvents: 'auto',
}}
onClick={e => {
e.stopPropagation();
onSplitRef.current?.(itemHostId);
}}
title="보조지표 분리"
>
<IconSplit />
</button>
)}
{copySlot >= 0 && (
<button
className="pane-btn pane-btn-copy"
style={{
position: 'absolute',
right: paneBtnRight(copySlot),
top: pl.topY + 5,
zIndex: 505,
opacity: isBeingDragged ? 0 : undefined,
transform: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: shiftTrans,
pointerEvents: 'auto',
}}
onClick={e => {
e.stopPropagation();
onDuplicateRef.current?.(item.id);
}}
title="보조지표 복사"
>
<IconCopy />
</button>
)}
{onRemove && (
<button
className="pane-btn pane-btn-close"
style={{
position: 'absolute',
right: paneBtnRight(closeSlot),
top: pl.topY + 5,
zIndex: 504,
opacity: isBeingDragged ? 0 : undefined,
transform: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: shiftTrans,
pointerEvents: 'auto',
}}
onClick={e => { e.stopPropagation(); onRemove(item.id); }}
title="지표 제거"
>
<IconClose />
</button>
)}
{/* ── 좌측 하단: 전체화면 버튼 (위) + 드래그 핸들 (아래) ── */}
{/* 드래그 핸들: pane 하단에서 30px 위 */}
{canReorder && (
<div
className="pane-drag-handle"
style={{
position: 'absolute',
left: 4,
top: paneBottomY - 30,
zIndex: 510,
opacity: isBeingDragged ? 0 : undefined,
transform: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: shiftTrans,
pointerEvents: 'auto',
}}
onPointerDown={e => handleDragHandlePointerDown(e, item.id)}
onMouseEnter={onLeaveName}
title="드래그하여 순서 변경"
>
<svg width="10" height="14" viewBox="0 0 10 14" fill="currentColor">
<circle cx="2.5" cy="2.5" r="1.5"/>
<circle cx="7.5" cy="2.5" r="1.5"/>
<circle cx="2.5" cy="7" r="1.5"/>
<circle cx="7.5" cy="7" r="1.5"/>
<circle cx="2.5" cy="11.5" r="1.5"/>
<circle cx="7.5" cy="11.5" r="1.5"/>
</svg>
</div>
)}
{/* 전체화면 / 복원 버튼: 드래그 핸들 위에 배치 */}
{(onExpand || (isFocused && onRestore)) && (
<button
className={`pane-btn pane-btn-expand${isFocused ? ' active' : ''}`}
style={{
position: 'absolute',
left: 4,
top: paneBottomY - 56,
zIndex: 504,
opacity: isBeingDragged ? 0 : undefined,
transform: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: shiftTrans,
pointerEvents: 'auto',
}}
onClick={e => {
e.stopPropagation();
if (isFocused) onRestore?.();
else onExpand?.(item.id);
}}
title={isFocused ? '전체 보기로 복원' : '이 지표만 보기'}
>
{isFocused ? <IconRestore /> : <IconExpand />}
</button>
)}
</React.Fragment>
);
})}