373 lines
15 KiB
TypeScript
373 lines
15 KiB
TypeScript
import React, { useRef, useEffect, useState } from 'react';
|
|
import { Box } from '@mui/material';
|
|
import { FibonacciTimezoneSettings, FibonacciLevel, FibonacciBaseline, defaultBaseline } from './FibonacciTimezoneDialog';
|
|
|
|
export interface FibonacciTimezoneObject {
|
|
id: string;
|
|
startTime: number; // Unix timestamp (seconds) - 기준점
|
|
endTime: number; // Unix timestamp (seconds) - 두 번째 클릭 지점 (방향 결정용)
|
|
baseInterval: number; // 방향 (1 = 오른쪽, -1 = 왼쪽)
|
|
settings: FibonacciTimezoneSettings;
|
|
}
|
|
|
|
interface FibonacciTimezoneOverlayProps {
|
|
objects: FibonacciTimezoneObject[];
|
|
chartWidth: number;
|
|
chartHeight: number; // 캔들차트 높이
|
|
totalChartHeight?: number; // 캔들차트 + 모든 보조지표 차트 높이 (보조지표까지 보기 옵션용)
|
|
timeScale: {
|
|
from: number;
|
|
to: number;
|
|
};
|
|
candleData: Array<{ time: string }>;
|
|
timeToX: (time: number) => number | null; // ✅ 차트의 timeToCoordinate 함수 전달 (null 가능)
|
|
onClick?: (objectId: string, event: React.MouseEvent) => void; // ✅ 클릭 핸들러
|
|
onDoubleClick?: (objectId: string) => void;
|
|
onDragStart?: (objectId: string, startX: number, startTime: number) => void;
|
|
isDrawing?: boolean; // 드로잉 중인지 여부
|
|
drawingStartTime?: number | null; // 드로잉 중 시작 시간
|
|
}
|
|
|
|
const FibonacciTimezoneOverlay: React.FC<FibonacciTimezoneOverlayProps> = ({
|
|
objects,
|
|
chartWidth,
|
|
chartHeight,
|
|
totalChartHeight,
|
|
timeScale,
|
|
candleData,
|
|
timeToX: timeToXProp, // ✅ prop으로 받은 함수
|
|
onClick,
|
|
onDoubleClick,
|
|
onDragStart,
|
|
isDrawing = false,
|
|
drawingStartTime = null,
|
|
}) => {
|
|
const svgRef = useRef<SVGSVGElement>(null);
|
|
|
|
// 시간을 캔들 인덱스로 변환 (가장 가까운 캔들 찾기)
|
|
const timeToIndex = (time: number): number => {
|
|
if (candleData.length === 0) return -1;
|
|
|
|
let nearestIndex = 0;
|
|
let minDiff = Infinity;
|
|
|
|
for (let i = 0; i < candleData.length; i++) {
|
|
const candleTime = new Date(candleData[i].time).getTime() / 1000;
|
|
const diff = Math.abs(candleTime - time);
|
|
|
|
if (diff < minDiff) {
|
|
minDiff = diff;
|
|
nearestIndex = i;
|
|
}
|
|
}
|
|
|
|
return nearestIndex;
|
|
};
|
|
|
|
// 캔들 인덱스를 시간으로 변환
|
|
const indexToTime = (index: number): number => {
|
|
if (index < 0 || index >= candleData.length) return 0;
|
|
return new Date(candleData[index].time).getTime() / 1000;
|
|
};
|
|
|
|
// ✅ prop으로 받은 timeToX 함수 사용
|
|
const timeToX = timeToXProp;
|
|
|
|
// 선 스타일을 SVG stroke-dasharray로 변환
|
|
const getStrokeDasharray = (style: string): string => {
|
|
switch (style) {
|
|
case 'dashed':
|
|
return '5,5';
|
|
case 'dotted':
|
|
return '2,2';
|
|
default:
|
|
return '0';
|
|
}
|
|
};
|
|
|
|
const handleDoubleClick = (objectId: string, e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
if (onDoubleClick) {
|
|
onDoubleClick(objectId);
|
|
}
|
|
};
|
|
|
|
const handleMouseDown = (objectId: string, e: React.MouseEvent, startTime: number) => {
|
|
e.stopPropagation();
|
|
e.preventDefault(); // 기본 드래그 동작 방지
|
|
console.log(`🖱️ [오버레이] mouseDown - objectId: ${objectId}, startTime: ${startTime}`);
|
|
if (onDragStart) {
|
|
const startX = e.clientX;
|
|
onDragStart(objectId, startX, startTime);
|
|
}
|
|
};
|
|
|
|
// ✅ 최대 높이 계산 (보조지표까지 보기가 활성화된 피보나치가 있을 경우)
|
|
const maxHeight = objects.some(obj => obj.settings.showAcrossIndicators) && totalChartHeight
|
|
? totalChartHeight
|
|
: chartHeight;
|
|
|
|
return (
|
|
<Box
|
|
sx={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
height: maxHeight,
|
|
pointerEvents: 'none',
|
|
zIndex: 50,
|
|
overflow: 'visible', // ✅ 오버플로우 허용
|
|
}}
|
|
>
|
|
<svg
|
|
ref={svgRef}
|
|
width="100%"
|
|
height={maxHeight}
|
|
style={{ position: 'absolute', top: 0, left: 0 }}
|
|
preserveAspectRatio="none"
|
|
>
|
|
{/* 드로잉 중일 때 기준선 표시 */}
|
|
{isDrawing && drawingStartTime !== null && (() => {
|
|
const x = timeToX(drawingStartTime);
|
|
if (x === null) return null;
|
|
return (
|
|
<g>
|
|
<line
|
|
x1={x}
|
|
y1={0}
|
|
x2={x}
|
|
y2={chartHeight}
|
|
stroke="rgba(255, 255, 0, 0.8)" // 노란색 기준선
|
|
strokeWidth={2}
|
|
strokeDasharray="5,5"
|
|
/>
|
|
<text
|
|
x={x + 5}
|
|
y={20}
|
|
fill="rgba(255, 255, 0, 0.8)"
|
|
fontSize="12px"
|
|
fontWeight="bold"
|
|
>
|
|
기준점
|
|
</text>
|
|
</g>
|
|
);
|
|
})()}
|
|
|
|
{/* 완성된 피보나치 타임존 표시 */}
|
|
{objects
|
|
.filter((obj) => obj.id !== 'temp-drawing') // 임시 드로잉 제외
|
|
.map((obj) => {
|
|
const startIndex = timeToIndex(obj.startTime);
|
|
const endIndex = timeToIndex(obj.endTime);
|
|
|
|
if (startIndex === -1 || endIndex === -1) {
|
|
return null;
|
|
}
|
|
|
|
// baseInterval은 방향 (1 = 오른쪽, -1 = 왼쪽)
|
|
const direction = obj.baseInterval;
|
|
|
|
const startX = timeToX(obj.startTime);
|
|
|
|
// ✅ 보조지표까지 보기 옵션에 따라 높이 결정
|
|
const effectiveHeight = obj.settings.showAcrossIndicators && totalChartHeight
|
|
? totalChartHeight
|
|
: chartHeight;
|
|
|
|
// 디버깅: 설정 확인
|
|
console.log(`🎨 [피보나치 렌더링] ID: ${obj.id}, 활성화된 레벨:`, obj.settings.levels.filter(l => l.enabled).map(l => l.value), `showAcrossIndicators: ${obj.settings.showAcrossIndicators}, effectiveHeight: ${effectiveHeight}`);
|
|
|
|
return (
|
|
<g
|
|
key={obj.id}
|
|
>
|
|
{/* ✅ 기준선 (baseline) 렌더링 */}
|
|
{(() => {
|
|
// ✅ baseline이 없으면 기본값 사용 (기존 저장된 데이터 호환)
|
|
const baseline = obj.settings.baseline || defaultBaseline;
|
|
console.log(`🎨 [피보나치 기준선 렌더링] ID: ${obj.id}, baseline:`, baseline);
|
|
const x = timeToX(obj.startTime);
|
|
if (x === null) return null;
|
|
|
|
return (
|
|
<g key={`${obj.id}-baseline`}>
|
|
{/* 투명한 클릭 영역 (드래그하기 쉽도록 넓게) */}
|
|
<line
|
|
x1={x}
|
|
y1={0}
|
|
x2={x}
|
|
y2={effectiveHeight}
|
|
stroke="transparent"
|
|
strokeWidth={40}
|
|
style={{ pointerEvents: 'auto', cursor: 'move' }}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (onClick) {
|
|
onClick(obj.id, e);
|
|
}
|
|
}}
|
|
onMouseDown={(e) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
if (onDragStart) {
|
|
const startX = e.clientX;
|
|
onDragStart(obj.id, startX, obj.startTime);
|
|
}
|
|
}}
|
|
onDoubleClick={(e) => {
|
|
e.stopPropagation();
|
|
if (onDoubleClick) {
|
|
onDoubleClick(obj.id);
|
|
}
|
|
}}
|
|
/>
|
|
{/* 기준선 */}
|
|
<line
|
|
x1={x}
|
|
y1={0}
|
|
x2={x}
|
|
y2={effectiveHeight}
|
|
stroke={baseline.color}
|
|
strokeWidth={baseline.lineWidth}
|
|
strokeDasharray={getStrokeDasharray(baseline.lineStyle)}
|
|
opacity={0.8}
|
|
style={{ pointerEvents: 'none' }}
|
|
/>
|
|
{/* 레이블 */}
|
|
<text
|
|
x={x + 5}
|
|
y={20}
|
|
fill={baseline.color}
|
|
fontSize="11px"
|
|
fontWeight="bold"
|
|
style={{ pointerEvents: 'none' }}
|
|
>
|
|
0
|
|
</text>
|
|
</g>
|
|
);
|
|
})()}
|
|
|
|
{/* 피보나치 레벨 선들 */}
|
|
{obj.settings.levels
|
|
.filter((level) => level.enabled)
|
|
.map((level, index) => {
|
|
|
|
// 캔들 인덱스 계산: 기준점 + (레벨 값 * 방향)
|
|
// 레벨 값이 직접 캔들 간격이 됨 (예: 1, 2, 3, 5, 8...)
|
|
const targetIndex = startIndex + (level.value * direction);
|
|
|
|
// ✅ 시간 계산 (범위를 벗어나도 추정 가능)
|
|
let time: number;
|
|
if (targetIndex >= 0 && targetIndex < candleData.length) {
|
|
// 범위 내: 정확한 캔들 시간 사용
|
|
time = indexToTime(targetIndex);
|
|
console.log(`📍 [오버레이] 레벨 ${level.value}: 범위 내 정확한 시간 사용, targetIndex=${targetIndex}, time=${time}`);
|
|
} else if (candleData.length >= 2) {
|
|
// 범위 외: 캔들 간격을 사용하여 시간 추정
|
|
const lastIndex = candleData.length - 1;
|
|
const firstTime = new Date(candleData[0].time).getTime() / 1000;
|
|
const lastTime = new Date(candleData[lastIndex].time).getTime() / 1000;
|
|
const candleInterval = (lastTime - firstTime) / lastIndex; // 평균 캔들 간격
|
|
|
|
const startTime = new Date(candleData[Math.max(0, Math.min(startIndex, lastIndex))].time).getTime() / 1000;
|
|
time = startTime + (level.value * direction * candleInterval);
|
|
console.log(`📍 [오버레이] 레벨 ${level.value}: 범위 외 시간 추정, targetIndex=${targetIndex}, time=${time}, interval=${candleInterval}`);
|
|
} else {
|
|
// 캔들 데이터가 부족하면 건너뛰기
|
|
console.log(`❌ [오버레이] 레벨 ${level.value}: 캔들 데이터 부족`);
|
|
return null;
|
|
}
|
|
|
|
const x = timeToX(time);
|
|
console.log(`📏 [오버레이] 레벨 ${level.value}: time=${time}, x=${x}, chartWidth=${chartWidth}`);
|
|
|
|
// ✅ x가 null이면 건너뛰기 (범위 체크는 매우 관대하게)
|
|
if (x === null) {
|
|
console.log(`❌ [오버레이] 레벨 ${level.value}: x가 null`);
|
|
return null;
|
|
}
|
|
|
|
// ✅ 매우 먼 거리만 제외 (차트의 10배 범위까지 허용)
|
|
const maxDistance = chartWidth * 10;
|
|
if (x < -maxDistance || x > chartWidth + maxDistance) {
|
|
console.log(`❌ [오버레이] 레벨 ${level.value}: x가 너무 멀리 벗어남 (x=${x}, 허용범위: ${-maxDistance} ~ ${chartWidth + maxDistance})`);
|
|
return null;
|
|
}
|
|
|
|
console.log(`✅ [오버레이] 레벨 ${level.value}: 렌더링 (x=${x})`);
|
|
|
|
|
|
return (
|
|
<g key={`${obj.id}-level-${index}`}>
|
|
{/* 투명한 클릭 영역 (드래그하기 쉽도록 넓게) */}
|
|
<line
|
|
x1={x}
|
|
y1={0}
|
|
x2={x}
|
|
y2={effectiveHeight}
|
|
stroke="transparent"
|
|
strokeWidth={40} // 넓은 클릭 영역
|
|
style={{ pointerEvents: 'auto', cursor: 'move' }}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (onClick) {
|
|
onClick(obj.id, e);
|
|
}
|
|
}}
|
|
onMouseDown={(e) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
console.log(`🖱️ [투명 영역] mouseDown - objectId: ${obj.id}, level: ${level.value}, x: ${x}`);
|
|
if (onDragStart) {
|
|
const startX = e.clientX;
|
|
onDragStart(obj.id, startX, obj.startTime);
|
|
}
|
|
}}
|
|
onDoubleClick={(e) => {
|
|
e.stopPropagation();
|
|
console.log(`🖱️ [투명 영역] doubleClick - objectId: ${obj.id}, level: ${level.value}`);
|
|
if (onDoubleClick) {
|
|
onDoubleClick(obj.id);
|
|
}
|
|
}}
|
|
/>
|
|
{/* 실제 수직선 */}
|
|
<line
|
|
x1={x}
|
|
y1={0}
|
|
x2={x}
|
|
y2={effectiveHeight}
|
|
stroke={level.color}
|
|
strokeWidth={level.lineWidth}
|
|
strokeDasharray={getStrokeDasharray(level.lineStyle)}
|
|
opacity={0.8}
|
|
style={{ pointerEvents: 'none' }} // 투명 영역만 이벤트 받도록
|
|
/>
|
|
{/* 레이블 */}
|
|
<text
|
|
x={x + 5}
|
|
y={20}
|
|
fill={level.color}
|
|
fontSize="11px"
|
|
fontWeight="bold"
|
|
style={{ pointerEvents: 'none' }} // 투명 영역만 이벤트 받도록
|
|
>
|
|
{level.value}
|
|
</text>
|
|
</g>
|
|
);
|
|
})}
|
|
</g>
|
|
);
|
|
})}
|
|
</svg>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default FibonacciTimezoneOverlay;
|
|
|