/** * 전략 평가 — 캔들 pane 세로 선택 영역 (드래그로 봉 이동) */ import React, { useCallback, useEffect, useRef, useState } from 'react'; import ReactDOM from 'react-dom'; import type { ChartManager } from '../../utils/ChartManager'; import type { OHLCVBar } from '../../types'; import { findNearestBarIndex } from '../../utils/analysisChartData'; interface Props { manager: ChartManager; bars: OHLCVBar[]; selectedBarIndex: number; onSelectedBarIndexChange: (index: number) => void; } interface BarBand { left: number; width: number; top: number; height: number; } function resolveBarBand( manager: ChartManager, bars: OHLCVBar[], index: number, ): BarBand | null { if (index < 0 || index >= bars.length) return null; const main = manager.getPaneLayouts().find(l => l.paneIndex === 0); if (!main || main.height < 8) return null; const centerX = manager.timeToX(bars[index].time); if (centerX == null || !Number.isFinite(centerX)) return null; let halfWidth = 7; if (index > 0) { const prevX = manager.timeToX(bars[index - 1].time); if (prevX != null) halfWidth = Math.max(5, Math.abs(centerX - prevX) * 0.42); } if (index < bars.length - 1) { const nextX = manager.timeToX(bars[index + 1].time); if (nextX != null) halfWidth = Math.max(halfWidth, Math.abs(nextX - centerX) * 0.42); } const plotWidth = manager.getCandlePaneTimeAxisBand()?.plotWidth ?? Math.max(40, manager.getContainer().clientWidth - 64); const width = Math.min(Math.max(halfWidth * 2, 10), 48); const left = Math.max(0, Math.min(plotWidth - width, centerX - width / 2)); return { left, width, top: main.topY, height: main.height, }; } function indexFromClientX( manager: ChartManager, bars: OHLCVBar[], clientX: number, ): number { const rect = manager.getContainer().getBoundingClientRect(); const chartX = clientX - rect.left; const time = manager.xToTime(chartX); if (time == null) return -1; return findNearestBarIndex(bars, time); } /** 차트 클릭 좌표 → 봉 index (전략선택봉 이동, 전략선택봉 영역 제외는 호출측) */ export function resolveBarIndexAtChartClick( manager: ChartManager, bars: OHLCVBar[], clientX: number, clientY: number, ): number { const idx = manager.resolveNearestBarIndexAtClientPoint(clientX, clientY); if (idx < 0 || idx >= bars.length) return -1; return idx; } const StrategyEvaluationBarSelector: React.FC = ({ manager, bars, selectedBarIndex, onSelectedBarIndexChange, }) => { const [band, setBand] = useState(null); const [dragIndex, setDragIndex] = useState(null); const draggingRef = useRef(false); const displayIndex = dragIndex ?? selectedBarIndex; const refreshBand = useCallback(() => { setBand(resolveBarBand(manager, bars, displayIndex)); }, [manager, bars, displayIndex]); useEffect(() => { refreshBand(); const ro = new ResizeObserver(refreshBand); ro.observe(manager.getContainer()); let raf = 0; const schedule = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(refreshBand); }; const unsubLayout = manager.subscribePaneLayout(schedule); const unsubViewport = manager.subscribeViewport(schedule); const timers = [0, 80, 200].map(ms => window.setTimeout(refreshBand, ms)); return () => { ro.disconnect(); cancelAnimationFrame(raf); unsubLayout(); unsubViewport(); timers.forEach(clearTimeout); }; }, [manager, refreshBand]); const finishDrag = useCallback((clientX: number) => { draggingRef.current = false; setDragIndex(null); const idx = indexFromClientX(manager, bars, clientX); if (idx >= 0 && idx !== selectedBarIndex) { onSelectedBarIndexChange(idx); } }, [manager, bars, selectedBarIndex, onSelectedBarIndexChange]); const onPointerDown = useCallback((e: React.PointerEvent) => { if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); draggingRef.current = true; e.currentTarget.setPointerCapture(e.pointerId); const onMove = (ev: PointerEvent) => { if (!draggingRef.current) return; ev.preventDefault(); ev.stopPropagation(); const idx = indexFromClientX(manager, bars, ev.clientX); if (idx >= 0) setDragIndex(idx); }; const onUp = (ev: PointerEvent) => { document.body.style.cursor = ''; document.body.style.userSelect = ''; finishDrag(ev.clientX); window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); window.removeEventListener('pointercancel', onUp); }; document.body.style.cursor = 'grabbing'; document.body.style.userSelect = 'none'; window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp); window.addEventListener('pointercancel', onUp); }, [manager, bars, finishDrag]); if (!band || bars.length === 0) return null; const bar = bars[displayIndex]; const timeLabel = bar ? new Date(bar.time * 1000).toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', }) : ''; return ReactDOM.createPortal(
{timeLabel && (
{timeLabel}
)}
, manager.getContainer(), ); }; export default StrategyEvaluationBarSelector;