Files
goldenChart/frontend/src/components/PaneLegend.tsx
T
2026-05-29 23:51:13 +09:00

672 lines
26 KiB
TypeScript

/**
* PaneLegend
* 각 서브 pane 좌측 상단에 인디케이터 명칭 + 현재 크로스헤어 값 표시.
* 드래그 핸들(⠿) / 전체화면 / 닫기 버튼 포함.
* 레이아웃 갱신 시 top transition 을 쓰지 않음 — 설정 변경 직후 라벨 이중 표시 방지.
*
* ─── 패인 인덱스 구조 ───────────────────────────────────────────────────────
* Pane 0 : 메인 캔들차트
* Pane 1 : 볼륨 (항상 고정, ChartManager._getNextSubPane 이 2부터 시작하는 이유)
* Pane 2+ : 보조지표 (RSI=2, MACD=3, ...)
* ────────────────────────────────────────────────────────────────────────────
*/
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';
import { bindWindowDrag, clientXYFromReact } from '../utils/pointerDrag';
import { getPaneHostId } from '../utils/indicatorPaneMerge';
import {
buildPaneItems,
resolvePaneItemLayout,
type PaneItem,
} from './paneLegendLayout';
// ── 타입 ─────────────────────────────────────────────────────────────────────
interface PaneCapture {
dataUrl: string;
cssWidth: number;
cssHeight: number;
}
type DropMode = 'reorder' | 'merge';
interface DragState {
draggingId: string;
ghostLabel: string;
ghostX: number;
ghostCursorY: number;
dropBeforeIndex: number;
dropMode: DropMode;
mergeTargetId: string | null;
paneCapture: PaneCapture | null;
}
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;
onReorder?: (fromId: string, insertBeforeId: string | null) => void;
/** 보조지표 pane 병합 */
onMerge?: (fromId: string, intoId: string) => void;
/** 드래그 핸들 — ChartRightToolbar 에서 호출 */
dragHandleRef?: React.MutableRefObject<((id: string, e: React.PointerEvent) => void) | null>;
/** 크로스헤어 이동 시 보조지표 수치 표시 */
crosshairInfoVisible?: boolean;
}
// ── 지그재그 배경색 — 홀짝 2단계로만 번갈아 적용 ───────────────────────────
const PANE_BG_COLORS = [
'rgba(255, 255, 255, 0.030)', // 짝수(0,2,4…): 아주 살짝 밝음
'rgba(255, 255, 255, 0.000)', // 홀수(1,3,5…): 기본 배경(투명)
];
// ── 캔버스 캡처 ───────────────────────────────────────────────────────────────
function capturePaneRegion(
containerEl: HTMLElement,
paneTopY: number,
paneHeight: number,
): PaneCapture | null {
const rows = Array.from(containerEl.querySelectorAll<HTMLTableRowElement>('tr'));
let targetRow: HTMLTableRowElement | null = null;
for (const row of rows) {
if (Math.abs(row.offsetTop - paneTopY) < 8) { targetRow = row; break; }
}
const canvases = Array.from(
(targetRow ?? containerEl).querySelectorAll<HTMLCanvasElement>('canvas'),
).filter(c => c.width > 0 && c.height > 0);
if (canvases.length === 0) return null;
const cssWidth = containerEl.clientWidth;
if (targetRow) {
const ref = canvases[0];
const off = document.createElement('canvas');
off.width = ref.width; off.height = ref.height;
const ctx = off.getContext('2d');
if (!ctx) return null;
for (const c of canvases) { try { ctx.drawImage(c, 0, 0); } catch { /* skip */ } }
try { return { dataUrl: off.toDataURL('image/webp', 0.92), cssWidth, cssHeight: paneHeight }; }
catch { return null; }
}
const ref = canvases[0];
const scaleY = ref.height / (containerEl.clientHeight || 1);
const srcY = Math.round(paneTopY * scaleY);
const srcH = Math.round(paneHeight * scaleY);
if (srcH <= 0) return null;
const off = document.createElement('canvas');
off.width = ref.width; off.height = srcH;
const ctx = off.getContext('2d');
if (!ctx) return null;
for (const c of canvases) {
const cs = c.height / (containerEl.clientHeight || 1);
try { ctx.drawImage(c, 0, Math.round(paneTopY * cs), c.width, Math.round(paneHeight * cs), 0, 0, ref.width, srcH); }
catch { /* skip */ }
}
try { return { dataUrl: off.toDataURL('image/webp', 0.92), cssWidth, cssHeight: paneHeight }; }
catch { return null; }
}
// ── 헬퍼 ─────────────────────────────────────────────────────────────────────
/**
* 드래그 중 각 pane의 translateY 오프셋 계산.
* draggingIdx → dropBeforeIdx 방향으로 이동 시 중간 pane들을 반대 방향으로 밀어낸다.
*/
function computeShiftY(
itemIdx: number, // 이 pane의 현재 배열 인덱스
draggingIdx: number, // 드래그 중인 pane의 인덱스
dropBefore: number, // 드롭 목표: 이 인덱스 앞에 삽입
dragHeight: number, // 드래그 중인 pane의 높이(px)
): number {
if (itemIdx === draggingIdx) return 0; // 드래그 중인 항목: 고스트로 대체
if (draggingIdx < dropBefore) {
// 아래로 이동 → 사이 pane 들을 위로
if (itemIdx > draggingIdx && itemIdx < dropBefore) return -dragHeight;
} else if (draggingIdx > dropBefore) {
// 위로 이동 → 사이 pane 들을 아래로
if (itemIdx >= dropBefore && itemIdx < draggingIdx) return dragHeight;
}
return 0;
}
// ── 아이콘 SVG ────────────────────────────────────────────────────────────────
const IconMerge = () => (
<svg width="28" height="28" viewBox="0 0 28 28" fill="none"
stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<line x1="14" y1="6" x2="14" y2="22"/>
<line x1="6" y1="14" x2="22" y2="14"/>
</svg>
);
// ── 컴포넌트 ──────────────────────────────────────────────────────────────────
const PaneLegend: React.FC<PaneLegendProps> = ({
manager, indicators, containerEl, paused = false,
onHoverName, onLeaveName, onDoubleClick,
onReorder, onMerge, dragHandleRef,
crosshairInfoVisible = true,
}) => {
const [paneItems, setPaneItems] = useState<PaneItem[]>([]);
const [paneLayouts, setPaneLayouts] = useState<Array<{ paneIndex: number; topY: number; height: number }>>([]);
const [layoutEpoch, setLayoutEpoch] = useState(0);
const [values, setValues] = useState<Record<string, number[]>>({});
const [dragState, setDragState] = useState<DragState | null>(null);
const retryRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const dragRef = useRef<{
id: string;
dropBeforeIndex: number;
dropMode: DropMode;
mergeTargetId: string | null;
} | null>(null);
const paneItemsRef = useRef(paneItems);
const paneLayoutsRef = useRef(paneLayouts);
const containerElRef = useRef(containerEl);
const onReorderRef = useRef(onReorder);
const onMergeRef = useRef(onMerge);
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]);
useEffect(() => { containerElRef.current = containerEl; }, [containerEl]);
useEffect(() => { onReorderRef.current = onReorder; }, [onReorder]);
useEffect(() => { onMergeRef.current = onMerge; }, [onMerge]);
useEffect(() => { indicatorsRef.current = indicators; }, [indicators]);
const syncPaneItems = useCallback(() => {
if (!containerElRef.current?.isConnected) return;
const nextItems = buildPaneItems(manager, indicators);
setPaneLayouts(manager.getPaneLayouts());
setPaneItems(nextItems);
setLayoutEpoch(e => e + 1);
}, [indicators, manager]);
const refreshLayouts = useCallback(() => {
setPaneLayouts(manager.getPaneLayouts());
}, [manager]);
const scheduleRetry = useCallback(() => {
syncPaneItems();
retryRef.current.forEach(clearTimeout);
retryRef.current = [
setTimeout(syncPaneItems, 0),
setTimeout(syncPaneItems, 50),
setTimeout(syncPaneItems, 200),
setTimeout(syncPaneItems, 500),
];
requestAnimationFrame(() => {
requestAnimationFrame(syncPaneItems);
});
}, [syncPaneItems]);
useEffect(() => {
if (paused) {
setPaneItems([]);
return;
}
setPaneItems([]);
scheduleRetry();
return () => retryRef.current.forEach(clearTimeout);
}, [scheduleRetry, indicators, paused]);
useEffect(() => {
const unsub = manager.subscribePaneLayout(() => scheduleRetry());
return unsub;
}, [manager, scheduleRetry]);
useEffect(() => {
const onResize = () => {
refreshLayouts();
syncPaneItems();
};
const ro = new ResizeObserver(onResize);
ro.observe(containerEl);
return () => ro.disconnect();
}, [containerEl, refreshLayouts, syncPaneItems]);
useEffect(() => {
if (!crosshairInfoVisible) {
setValues({});
return;
}
const unsub = manager.subscribeCrosshair((p: MouseEventParams<Time>) => {
if (!p.time) { setValues({}); return; }
setValues(manager.getIndicatorValuesByIdFromParams(p));
});
return unsub;
}, [manager, crosshairInfoVisible]);
// ── 드롭 라인 Y ────────────────────────────────────────────────────────────
const getDropLineY = useCallback((idx: number): number | null => {
const items = paneItemsRef.current;
const layouts = paneLayoutsRef.current;
if (!items.length) return null;
const rect = containerElRef.current.getBoundingClientRect();
if (idx <= 0) {
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]);
return pl ? rect.top + pl.topY + pl.height : null;
}
const pl = resolvePaneItemLayout(manager, items[idx - 1]);
return pl ? rect.top + pl.topY + pl.height : null;
}, [manager]);
const unbindPaneDragRef = useRef<(() => void) | null>(null);
// ── 드래그 핸들 (마우스·터치) ───────────────────────────────────────────
const handleDragHandlePointerDown = useCallback((e: React.PointerEvent, id: string) => {
if (!onReorderRef.current) return;
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
try {
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
} catch {
/* ignore */
}
const item = paneItemsRef.current.find(i => i.id === id);
const fromIndex = paneItemsRef.current.findIndex(i => i.id === id);
if (!item || fromIndex === -1) return;
const layout = resolvePaneItemLayout(manager, item);
const paneCapture = layout
? capturePaneRegion(containerElRef.current, layout.topY, layout.height)
: null;
const { x, y } = clientXYFromReact(e);
dragRef.current = {
id,
dropBeforeIndex: fromIndex,
dropMode: 'reorder',
mergeTargetId: null,
};
setDragState({
draggingId: id,
ghostLabel: item.label,
ghostX: x + 14,
ghostCursorY: y,
dropBeforeIndex: fromIndex,
dropMode: 'reorder',
mergeTargetId: null,
paneCapture,
});
document.body.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
const handleKeyDown = (ev: KeyboardEvent) => { if (ev.key === 'Escape') cleanup(); };
const cleanup = () => {
dragRef.current = null;
setDragState(null);
document.body.style.cursor = '';
document.body.style.userSelect = '';
unbindPaneDragRef.current?.();
unbindPaneDragRef.current = null;
document.removeEventListener('keydown', handleKeyDown);
};
const finishDrag = () => {
const dr = dragRef.current;
if (dr) {
if (dr.dropMode === 'merge' && dr.mergeTargetId) {
onMergeRef.current?.(dr.id, dr.mergeTargetId);
} else {
const items = paneItemsRef.current;
const fi = items.findIndex(i => i.id === dr.id);
const noOp = dr.dropBeforeIndex === fi || dr.dropBeforeIndex === fi + 1;
if (!noOp) {
const before = dr.dropBeforeIndex >= items.length
? null
: items[dr.dropBeforeIndex].id;
onReorderRef.current?.(dr.id, before);
}
}
}
cleanup();
};
unbindPaneDragRef.current?.();
unbindPaneDragRef.current = bindWindowDrag(
({ x: cx, y: cy }) => {
if (!dragRef.current) return;
const items = paneItemsRef.current;
const layouts = paneLayoutsRef.current;
const inds = indicatorsRef.current;
const rect = containerElRef.current.getBoundingClientRect();
const mouseY = cy - rect.top;
const dragHost = getPaneHostId(dragRef.current.id, inds);
let dropMode: DropMode = 'reorder';
let mergeTargetId: string | null = null;
let drop = items.length;
for (const item of items) {
if (getPaneHostId(item.id, inds) === dragHost) continue;
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) {
dropMode = 'merge';
mergeTargetId = item.id;
break;
}
}
if (dropMode === 'reorder') {
for (let i = 0; i < items.length; i++) {
const l = resolvePaneItemLayout(manager, items[i]);
if (l && mouseY < l.topY + l.height / 2) {
drop = i;
break;
}
}
}
dragRef.current.dropBeforeIndex = drop;
dragRef.current.dropMode = dropMode;
dragRef.current.mergeTargetId = mergeTargetId;
document.body.style.cursor = dropMode === 'merge' ? 'copy' : 'grabbing';
setDragState(prev => prev ? {
...prev,
dropBeforeIndex: drop,
dropMode,
mergeTargetId,
ghostX: cx + 14,
ghostCursorY: cy,
} : null);
},
finishDrag,
);
document.addEventListener('keydown', handleKeyDown);
}, [manager]);
useEffect(() => {
if (!dragHandleRef) return;
dragHandleRef.current = (id, e) => handleDragHandlePointerDown(e, id);
return () => { dragHandleRef.current = null; };
}, [dragHandleRef, handleDragHandlePointerDown]);
// ── 렌더 ─────────────────────────────────────────────────────────────────
const isDragging = dragState !== null;
const dropLineY = isDragging && dragState!.dropMode === 'reorder'
? getDropLineY(dragState!.dropBeforeIndex)
: null;
const containerRect = containerEl.getBoundingClientRect();
void layoutEpoch;
const draggingHostId = isDragging
? getPaneHostId(dragState!.draggingId, indicators)
: '';
const draggingItemIdx = isDragging
? paneItems.findIndex(i => i.id === draggingHostId)
: -1;
const draggingLayout = draggingItemIdx >= 0
? resolvePaneItemLayout(manager, paneItems[draggingItemIdx])
: null;
const mergeTargetLayout = isDragging && dragState?.mergeTargetId
? (() => {
const t = paneItems.find(i => i.id === dragState.mergeTargetId);
return t ? resolvePaneItemLayout(manager, t) : null;
})()
: null;
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);
if (!pl) return null;
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 hoverScreenX = containerRect.left;
const hoverScreenY = containerRect.top + pl.topY;
const isPaneHidden = item.hidden === true;
const bgColor = PANE_BG_COLORS[itemIdx % PANE_BG_COLORS.length];
// ── 드래그 중: 이 pane의 translateY 오프셋 ──────────────────────────
const shiftY = (isDragging && draggingItemIdx >= 0 && draggingLayout)
? computeShiftY(
itemIdx,
draggingItemIdx,
dragState!.dropMode === 'merge' ? draggingItemIdx : dragState!.dropBeforeIndex,
draggingLayout.height,
)
: 0;
const shiftTrans = isDragging ? 'transform 0.18s ease' : undefined;
return (
<React.Fragment key={item.id}>
{/* ── 배경색 오버레이 ─────────────────────────────────────── */}
<div
className="pane-bg-overlay"
style={{
position: 'absolute',
left: 0,
top: pl.topY,
width: '100%',
height: pl.height,
background: isBeingDragged
? 'transparent'
: isDragging
? bgColor.replace(/[\d.]+\)$/, '0.12)') // 드래그 중 살짝 강조
: bgColor,
zIndex: 480,
pointerEvents: 'none',
transform: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: shiftTrans,
}}
/>
{/* ── 레전드 레이블 (pane 좌측 상단) ─────────────────────── */}
<div
className="pane-legend-item"
style={{
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: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: shiftTrans,
}}
>
<span
className={`pane-legend-name${isPaneHidden ? ' pane-legend-name--hidden' : ''}`}
onMouseEnter={() => !isDragging && onHoverName(item.id, hoverScreenX, hoverScreenY)}
onMouseLeave={() => !isDragging && onLeaveName()}
onDoubleClick={() => !isDragging && onDoubleClick?.(item.id)}
>
{item.label}
</span>
{crosshairInfoVisible && itemVals.map((v, i) => isNaN(v) ? null : (
<span key={i} className="pane-legend-val"
style={{ color: item.plotColors[i] ?? 'var(--text)' }}>
{formatVal(v)}
</span>
))}
</div>
</React.Fragment>
);
})}
{/* ── 드래그 중 오버레이 ─────────────────────────────────────────── */}
{isDragging && draggingLayout && (() => {
const ghostColor = 'rgba(122, 162, 247';
const ghostTop = dragState.ghostCursorY - draggingLayout.height / 2;
return (
<>
{/* ① 전체 지표 영역 dim — 고스트·shift 가 더 선명하게 보이도록 */}
{paneItems.length > 0 && (() => {
const firstL = resolvePaneItemLayout(manager, paneItems[0]);
const lastL = resolvePaneItemLayout(manager, paneItems[paneItems.length - 1]);
if (!firstL || !lastL) return null;
return (
<div style={{
position: 'fixed',
left: containerRect.left,
top: containerRect.top + firstL.topY,
width: containerRect.width,
height: lastL.topY + lastL.height - firstL.topY,
background: 'rgba(8, 10, 20, 0.38)',
zIndex: 478,
pointerEvents: 'none',
}} />
);
})()}
{/* ② 원본 위치 placeholder (파선 테두리) */}
<div className="pane-drag-placeholder" style={{
position: 'fixed',
left: containerRect.left,
top: containerRect.top + draggingLayout.topY,
width: containerRect.width,
height: draggingLayout.height,
zIndex: 490,
}}/>
{/* ③ 드롭: 합치기(+) 또는 이동 안내선 */}
{dragState.dropMode === 'merge' && mergeTargetLayout ? (
<div
className="pane-drop-merge"
style={{
position: 'fixed',
left: containerRect.left,
top: containerRect.top + mergeTargetLayout.topY,
width: containerRect.width,
height: mergeTargetLayout.height,
}}
>
<div className="pane-drop-merge-badge">
<IconMerge />
<span>합치기</span>
</div>
</div>
) : dropLineY !== null ? (
<>
<div className="pane-drop-line" style={{
top: dropLineY,
left: containerRect.left,
width: containerRect.width,
}}/>
<div
className="pane-drop-move-hint"
style={{
top: dropLineY - 22,
left: containerRect.left + 8,
}}
>
이동
</div>
</>
) : null}
{/* ④ 고스트: 캔버스 캡처 성공 → 실제 차트 이미지, 실패 → 색상 블록 */}
{dragState.paneCapture ? (
<div className="pane-drag-ghost-chart" style={{
position: 'fixed',
left: containerRect.left,
top: ghostTop,
width: dragState.paneCapture.cssWidth,
height: draggingLayout.height,
}}>
<img
src={dragState.paneCapture.dataUrl}
style={{ width: '100%', height: '100%', display: 'block' }}
alt=""
/>
<div className="pane-drag-ghost-label">
{dragState.ghostLabel}
<span className="pane-drag-ghost-mode">
{dragState.dropMode === 'merge' ? ' · 합치기' : ' · 이동'}
</span>
</div>
</div>
) : (
/* 색상 블록 고스트 */
<div className="pane-drag-ghost-block" style={{
position: 'fixed',
left: containerRect.left,
top: ghostTop,
width: containerRect.width,
height: draggingLayout.height,
background: ghostColor + ', 0.20)',
zIndex: 700,
pointerEvents: 'none',
border: '2px solid rgba(122, 162, 247, 0.7)',
borderRadius: 3,
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<span style={{
color: '#fff', fontSize: 13, fontWeight: 700,
textShadow: '0 1px 4px rgba(0,0,0,0.7)',
pointerEvents: 'none', userSelect: 'none',
}}>
{dragState.ghostLabel}
<span style={{ opacity: 0.75, fontWeight: 500, marginLeft: 6 }}>
{dragState.dropMode === 'merge' ? '합치기' : '이동'}
</span>
</span>
</div>
)}
</>
);
})()}
</div>
);
return createPortal(layer, portalHost);
};
function formatVal(v: number): string {
const abs = Math.abs(v);
if (abs >= 100_000) return v.toFixed(0);
if (abs >= 1_000) return v.toFixed(1);
if (abs >= 1) return v.toFixed(2);
return v.toFixed(4);
}
export default PaneLegend;