Files
goldenChart/frontend_golden/src/components/FibonacciTimezoneOverlay.tsx
T
2026-05-23 15:11:48 +09:00

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;