199 lines
6.0 KiB
TypeScript
199 lines
6.0 KiB
TypeScript
/**
|
|
* 전략 평가 — 캔들 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<Props> = ({
|
|
manager,
|
|
bars,
|
|
selectedBarIndex,
|
|
onSelectedBarIndexChange,
|
|
}) => {
|
|
const [band, setBand] = useState<BarBand | null>(null);
|
|
const [dragIndex, setDragIndex] = useState<number | null>(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<HTMLDivElement>) => {
|
|
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(
|
|
<div className="seval-bar-selector-layer" aria-hidden>
|
|
<div
|
|
data-no-chart-pan
|
|
className={`seval-bar-selector${dragIndex != null ? ' seval-bar-selector--dragging' : ''}`}
|
|
style={{
|
|
left: `${band.left}px`,
|
|
top: `${band.top}px`,
|
|
width: `${band.width}px`,
|
|
height: `${band.height}px`,
|
|
}}
|
|
onPointerDown={onPointerDown}
|
|
title="드래그하여 분석 봉 이동"
|
|
>
|
|
<div className="seval-bar-selector__shine" />
|
|
<div className="seval-bar-selector__handle" />
|
|
{timeLabel && (
|
|
<div className="seval-bar-selector__label">{timeLabel}</div>
|
|
)}
|
|
</div>
|
|
</div>,
|
|
manager.getContainer(),
|
|
);
|
|
};
|
|
|
|
export default StrategyEvaluationBarSelector;
|