672 lines
26 KiB
TypeScript
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;
|