/** * 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('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('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 = () => ( ); // ── 컴포넌트 ────────────────────────────────────────────────────────────────── const PaneLegend: React.FC = ({ manager, indicators, containerEl, paused = false, onHoverName, onLeaveName, onDoubleClick, onReorder, onMerge, dragHandleRef, crosshairInfoVisible = true, }) => { const [paneItems, setPaneItems] = useState([]); const [paneLayouts, setPaneLayouts] = useState>([]); const [layoutEpoch, setLayoutEpoch] = useState(0); const [values, setValues] = useState>({}); const [dragState, setDragState] = useState(null); const retryRef = useRef[]>([]); 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(null); /** React portal 전용 호스트 — chart-container 직접 조작·removeChild 경합 방지 */ useEffect(() => { const sel = ':scope > .pane-legend-portal-host'; let host = containerEl.querySelector(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