/* eslint-disable @typescript-eslint/no-redeclare */ import React, { useEffect, useRef, useState, forwardRef, useImperativeHandle, useMemo, useCallback } from 'react'; import { createChart, ColorType, IChartApi, ISeriesApi, Time } from 'lightweight-charts'; import { Box, Typography, IconButton, CircularProgress, Chip, Slider, Dialog, DialogTitle, DialogContent, DialogActions, Button, Table, TableBody, TableRow, TableCell, Menu, MenuItem, ListItemIcon, ListItemText, Divider, Tooltip } from '@mui/material'; import { DragIndicator, VisibilityOff, Settings, Delete, MoreVert, Star, StarBorder, ContentCopy, ViewList, Schedule, PushPin, ZoomOut, ZoomIn, ChevronLeft, ChevronRight, RestartAlt, Fullscreen, FullscreenExit, Add, Remove, Close } from '@mui/icons-material'; import { DndContext, closestCenter, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { CandleData, IndicatorData } from '../services/backtestApi'; import { useAppTheme, ThemeMode } from '../contexts/ThemeContext'; import { useIndicatorVisibility } from '../contexts/IndicatorVisibilityContext'; import { useChartColors } from '../contexts/ChartColorContext'; import { useIndicatorSettings } from '../contexts/IndicatorSettingsContext'; import { useUpbitData } from '../contexts/UpbitDataContext'; /** * ✅ TrendIndicatorBar는 이제 별도 파일 (TrendIndicatorBar.tsx)에 있습니다 * * 아래는 CandlestickChart 컴포넌트입니다 */ // ❌ 기존 TrendIndicatorBar 정의 제거됨 (별도 파일로 이동) // 이제 TrendIndicatorBar.tsx에서 import됩니다 /** * 선행스팬 ON 시 과거에는 서브 봉 수가 메인보다 적어 시간(setVisibleRange) 동기화가 필요했으나, * placeholder·투명 범위 시리즈로 논리봉이 맞춰진 경우가 많다. 메인은 handleScroll 등에서 * setVisibleLogicalRange로 이동하므로, 서브에 setVisibleRange만 적용하면 fixLeftEdge 등에서 * 동일 시각이 픽셀상 어긋날 수 있다 → 가시 논리 범위가 있으면 항상 그것을 우선한다. */ /** * 캔들+보조지표 세로 스택에서 우측 가격축 너비를 통일해야 timeToCoordinate·전역 크로스헤어 X가 패널 간 일치한다. * (메인 100 vs CCI 112 등이면 동일 시각이 픽셀상 좌우로 어긋남 — 일목 선행 시 더 두드러짐) */ function stackedPanelsRightPriceScaleMinWidth( subPanels: Array<{ enabled?: boolean; type: string }> | undefined, isMobile: boolean ): number { if (isMobile) return 40; const enabled = subPanels?.filter((p) => p.enabled) ?? []; let w = 100; for (const p of enabled) { if (p.type === 'cci') w = Math.max(w, 112); else if (p.type === 'dmi') w = Math.max(w, 128); } return w; } function applyMainVisibleToSubCharts( mainTimeScale: ReturnType, subCharts: Record, useTimeRangeSync?: boolean ): void { const useTimeFallback = !!useTimeRangeSync; const logicalRange = mainTimeScale.getVisibleLogicalRange(); const visibleTime = mainTimeScale.getVisibleRange(); Object.values(subCharts).forEach((subChart) => { if (!subChart) return; try { if (logicalRange) { subChart.timeScale().setVisibleLogicalRange(logicalRange); return; } if ( useTimeFallback && visibleTime && visibleTime.from != null && visibleTime.to != null ) { subChart.timeScale().setVisibleRange({ from: visibleTime.from as Time, to: visibleTime.to as Time, }); } } catch { // ignore } }); } /** * 일목균형표 판단 로직 * * @param currentPrice 현재 종가 * @param tenkan 전환선 값 * @param kijun 기준선 값 * @param senkouA 선행스팬1 값 (현재 시점의 26일 미래) * @param senkouB 선행스팬2 값 (현재 시점의 26일 미래) * @param chikou 후행스팬 값 (26일 전 가격) * @returns 시장 분석 결과 */ interface IchimokuAnalysis { cloudPosition: '구름 위' | '구름 내부' | '구름 아래' | '불명'; tkCross: '골든크로스' | '데드크로스' | '중립'; chikouTrend: '상승 추세' | '하락 추세' | '중립'; cloudThickness: number; cloudType: '상승 구름' | '하락 구름' | '불명'; signal: '강한 매수' | '매수' | '중립' | '매도' | '강한 매도'; } function analyzeIchimoku( currentPrice: number, tenkan?: number, kijun?: number, senkouA?: number, senkouB?: number, chikou?: number, previousPrice?: number ): IchimokuAnalysis { const analysis: IchimokuAnalysis = { cloudPosition: '불명', tkCross: '중립', chikouTrend: '중립', cloudThickness: 0, cloudType: '불명', signal: '중립' }; // 1. 구름 위치 판단 if (senkouA !== undefined && senkouB !== undefined) { const cloudTop = Math.max(senkouA, senkouB); const cloudBottom = Math.min(senkouA, senkouB); analysis.cloudThickness = Math.abs(senkouA - senkouB); analysis.cloudType = senkouA >= senkouB ? '상승 구름' : '하락 구름'; if (currentPrice > cloudTop) { analysis.cloudPosition = '구름 위'; } else if (currentPrice < cloudBottom) { analysis.cloudPosition = '구름 아래'; } else { analysis.cloudPosition = '구름 내부'; } } // 2. 전환선 vs 기준선 (TK Cross) if (tenkan !== undefined && kijun !== undefined) { if (tenkan > kijun) { analysis.tkCross = '골든크로스'; } else if (tenkan < kijun) { analysis.tkCross = '데드크로스'; } } // 3. 후행스팬 추세 (현재 가격 vs 26일 전 가격) if (chikou !== undefined && previousPrice !== undefined) { if (currentPrice > previousPrice) { analysis.chikouTrend = '상승 추세'; } else if (currentPrice < previousPrice) { analysis.chikouTrend = '하락 추세'; } } // 4. 종합 신호 판단 let score = 0; // 구름 위치 점수 if (analysis.cloudPosition === '구름 위') score += 2; else if (analysis.cloudPosition === '구름 아래') score -= 2; // TK Cross 점수 if (analysis.tkCross === '골든크로스') score += 1; else if (analysis.tkCross === '데드크로스') score -= 1; // 후행스팬 점수 if (analysis.chikouTrend === '상승 추세') score += 1; else if (analysis.chikouTrend === '하락 추세') score -= 1; // 구름 타입 점수 if (analysis.cloudType === '상승 구름') score += 1; else if (analysis.cloudType === '하락 구름') score -= 1; // 최종 신호 if (score >= 4) analysis.signal = '강한 매수'; else if (score >= 2) analysis.signal = '매수'; else if (score <= -4) analysis.signal = '강한 매도'; else if (score <= -2) analysis.signal = '매도'; else analysis.signal = '중립'; return analysis; } /** * 볼린저밴드 영역 Canvas Overlay 컴포넌트 * 상한선과 하한선 사이를 canvas polygon으로 채움 */ interface BollingerBandOverlayProps { chartRef: React.RefObject; candleSeriesRef: React.RefObject | null>; bandData: Array<{ time: number; upper: number; lower: number }>; priceRange: { min: number; max: number }; containerWidth: number; containerHeight: number; backgroundColor?: string; yAxisScaleFactor?: number; // ✅ Y축 스케일 팩터 (밴드 영역 재그리기 트리거용) } const BollingerBandOverlay: React.FC = ({ chartRef, candleSeriesRef, bandData, priceRange, containerWidth, containerHeight, backgroundColor = 'rgba(33, 150, 243, 0.15)', // ✅ 기본 투명도 증가 (더 잘 보이도록) yAxisScaleFactor = 1.0 }) => { const canvasRef = useRef(null); console.log(`🎨 [BollingerBandOverlay] 컴포넌트 렌더링:`, { dataLength: bandData.length, backgroundColor, containerWidth, containerHeight, hasChartRef: !!chartRef.current, hasCandleSeriesRef: !!candleSeriesRef.current }); const drawBand = useCallback(() => { const canvas = canvasRef.current; const chart = chartRef.current; const candleSeries = candleSeriesRef.current; console.log(`🎨 [BollingerBand drawBand] 시작:`, { hasCanvas: !!canvas, hasChart: !!chart, hasSeries: !!candleSeries, dataLength: bandData.length, backgroundColor }); if (!canvas || !chart || !candleSeries || bandData.length < 2) { console.warn('⚠️ [BollingerBand] 렌더링 조건 미충족'); return; } const ctx = canvas.getContext('2d'); if (!ctx) return; // Canvas 크기 설정 (Retina 디스플레이 대응) const dpr = window.devicePixelRatio || 1; canvas.width = containerWidth * dpr; canvas.height = containerHeight * dpr; canvas.style.width = `${containerWidth}px`; canvas.style.height = `${containerHeight}px`; ctx.scale(dpr, dpr); // 초기화 ctx.clearRect(0, 0, canvas.width, canvas.height); try { const timeScale = chart.timeScale(); // ✅ Y축 라벨 영역 제외 (우측 100px는 Y축 라벨 영역) const priceScaleWidth = 100; // Y축 라벨 영역 너비 const chartDrawWidth = containerWidth - priceScaleWidth; // 실제 차트 그리기 영역 너비 // ✅ 클리핑 영역 설정: Y축 라벨 영역을 제외한 영역에만 그리기 ctx.save(); ctx.beginPath(); ctx.rect(0, 0, chartDrawWidth, containerHeight); ctx.clip(); const priceToY = (price: number): number => { const coord = candleSeries.priceToCoordinate(price); if (coord === null) { return containerHeight / 2; } return coord; }; // 각 세그먼트를 polygon으로 그림 for (let i = 0; i < bandData.length - 1; i++) { const curr = bandData[i]; const next = bandData[i + 1]; // 시간 좌표 변환 const x1 = timeScale.timeToCoordinate(curr.time as Time); const x2 = timeScale.timeToCoordinate(next.time as Time); if (x1 === null || x2 === null) continue; // 화면 밖의 세그먼트는 스킵 // ✅ chartDrawWidth를 기준으로 판단 if ((x1 < -100 && x2 < -100) || (x1 > chartDrawWidth + 100 && x2 > chartDrawWidth + 100)) { continue; } // 가격을 Y좌표로 변환 const y1Upper = priceToY(curr.upper); const y1Lower = priceToY(curr.lower); const y2Upper = priceToY(next.upper); const y2Lower = priceToY(next.lower); ctx.fillStyle = backgroundColor; // Polygon 그리기 (4개 꼭지점) - 상한선과 하한선 사이를 채움 ctx.beginPath(); ctx.moveTo(x1, y1Upper); // 현재 시점의 상한선 ctx.lineTo(x2, y2Upper); // 다음 시점의 상한선 ctx.lineTo(x2, y2Lower); // 다음 시점의 하한선 ctx.lineTo(x1, y1Lower); // 현재 시점의 하한선 ctx.closePath(); ctx.fill(); } // ✅ 클리핑 영역 복원 ctx.restore(); console.log(`✅ [BollingerBand] 배경 렌더링 완료: ${bandData.length}개 포인트, 색상: ${backgroundColor}, 그리기 영역: ${chartDrawWidth}px (Y축 제외)`); } catch (error) { console.error('❌ [BollingerBand] Canvas 그리기 오류:', error); } }, [chartRef, candleSeriesRef, bandData, priceRange, containerWidth, containerHeight, backgroundColor, yAxisScaleFactor]); // 초기 그리기 useEffect(() => { drawBand(); }, [drawBand]); // 차트 변경 시 다시 그리기 useEffect(() => { const chart = chartRef.current; if (!chart) return; const timeScale = chart.timeScale(); const handleVisibleRangeChange = () => { drawBand(); }; timeScale.subscribeVisibleLogicalRangeChange(handleVisibleRangeChange); return () => { timeScale.unsubscribeVisibleLogicalRangeChange(handleVisibleRangeChange); }; }, [chartRef, drawBand]); return ( ); }; /** * 일목균형표 구름 영역 Canvas Overlay 컴포넌트 * 업비트 방식: 두 선(선행스팬1, 2) 사이를 canvas polygon으로 채움 * * 핵심 구현: * 1. 미래 시점(26일 선행)까지 포함한 구름 영역 표시 * 2. 차트의 전체 가격 범위와 동기화된 정확한 Y좌표 계산 * 3. 조건부 색상 (상승: 녹색, 하락: 빨강) */ interface IchimokuCloudOverlayProps { chartRef: React.RefObject; candleSeriesRef: React.RefObject | null>; // ✅ 정확한 Y좌표 변환을 위해 추가 isDisposedRef?: React.RefObject; // dispose 시 콜백 스킵용 cloudData: Array<{ time: number; senkouA: number; senkouB: number }>; priceRange: { min: number; max: number }; // 차트 전체 가격 범위 containerWidth: number; containerHeight: number; cloudBullishColor?: string; // 상승 구름 색상 cloudBearishColor?: string; // 하락 구름 색상 yAxisScaleFactor?: number; // ✅ Y축 스케일 팩터 (구름 영역 재그리기 트리거용) /** 선행스팬 선 (Canvas로 그려 lightweight-charts Line 시리즈 "Value is null" 회피) */ senkouALineData?: Array<{ time: number; value: number }>; senkouBLineData?: Array<{ time: number; value: number }>; senkouAColor?: string; senkouBColor?: string; senkouAWidth?: number; senkouBWidth?: number; /** 전환선/기준선 (Canvas로 그려 addLineSeries "Value is null" 회피) */ tenkanLineData?: Array<{ time: number; value: number }>; kijunLineData?: Array<{ time: number; value: number }>; tenkanColor?: string; kijunColor?: string; tenkanWidth?: number; kijunWidth?: number; } const IchimokuCloudOverlay: React.FC = ({ chartRef, candleSeriesRef, isDisposedRef, cloudData, priceRange, containerWidth, containerHeight, cloudBullishColor = 'rgba(239, 83, 80, 0.2)', cloudBearishColor = 'rgba(38, 166, 154, 0.2)', yAxisScaleFactor = 1.0, senkouALineData = [], senkouBLineData = [], senkouAColor = '#26a69a', senkouBColor = '#ef5350', senkouAWidth = 1, senkouBWidth = 1, tenkanLineData = [], kijunLineData = [], tenkanColor = '#2196f3', kijunColor = '#f44336', tenkanWidth = 1, kijunWidth = 1.5, }) => { const canvasRef = useRef(null); const drawCloud = useCallback(() => { if (isDisposedRef?.current) return; const canvas = canvasRef.current; const chart = chartRef.current; const candleSeries = candleSeriesRef.current; const hasCloud = cloudData.length >= 2; const hasSenkouA = senkouALineData.length >= 2; const hasSenkouB = senkouBLineData.length >= 2; const hasTenkan = tenkanLineData.length >= 2; const hasKijun = kijunLineData.length >= 2; const hasAnythingToDraw = hasCloud || hasSenkouA || hasSenkouB || hasTenkan || hasKijun; if (!canvas || !chart || !candleSeries || !hasAnythingToDraw) { if (!candleSeries) console.warn('⚠️ [IchimokuCloud] candleSeries 없음'); return; } const ctx = canvas.getContext('2d'); if (!ctx) return; // Canvas 크기 설정 (Retina 디스플레이 대응) const dpr = window.devicePixelRatio || 1; canvas.width = containerWidth * dpr; canvas.height = containerHeight * dpr; canvas.style.width = `${containerWidth}px`; canvas.style.height = `${containerHeight}px`; ctx.scale(dpr, dpr); // 초기화 ctx.clearRect(0, 0, canvas.width, canvas.height); try { const timeScale = chart.timeScale(); console.log(`📊 [IchimokuCloud] priceRange prop: ${priceRange.min.toFixed(0)} ~ ${priceRange.max.toFixed(0)}`); // ✅ Y축 라벨 영역 제외 (우측 100px는 Y축 라벨 영역) const priceScaleWidth = 100; // Y축 라벨 영역 너비 const chartDrawWidth = containerWidth - priceScaleWidth; // 실제 차트 그리기 영역 너비 // ✅ 클리핑 영역 설정: Y축 라벨 영역을 제외한 영역에만 그리기 ctx.save(); ctx.beginPath(); ctx.rect(0, 0, chartDrawWidth, containerHeight); ctx.clip(); /** * ✅ 가격을 Y픽셀 좌표로 변환 * priceToCoordinate가 숫자를 반환하면 **항상 그 좌표만** 쓴다(클램프만 적용). * 이전에는 0~containerHeight 밖이면 priceRange 선형 보간으로 바꿔, 같은 구름 구간 안에서 * 차트 좌표계와 보간 좌표계가 섞여 화면 전체 높이로 번지는 삼각형/직사각형이 생길 수 있었다. * coordinate가 null일 때만 priceRange 폴백. */ const priceToY = (price: number): number => { const coord = candleSeries.priceToCoordinate(price); if (coord !== null && isFinite(coord)) { if (containerHeight <= 0) return 0; return Math.max(0, Math.min(containerHeight - 1, coord)); } const { min, max } = priceRange; if (max > min && isFinite(min) && isFinite(max)) { const normalized = Math.max(0, Math.min(1, (price - min) / (max - min))); return containerHeight * (1 - normalized); } return containerHeight / 2; }; /** 선행 A·B가 선형 보간 구간 (0~1) 안에서 교차하는 비율 s. 없으면 null (TradingView/업비트형 채움: 자기교차 사각형 fill 방지) */ const senkouCrossFraction = (a1: number, b1: number, a2: number, b2: number): number | null => { const d1 = a1 - b1; const d2 = a2 - b2; const denom = d1 - d2; if (!isFinite(denom) || Math.abs(denom) < 1e-14) return null; const s = d1 / denom; if (!isFinite(s) || s <= 1e-9 || s >= 1 - 1e-9) return null; return s; }; // 각 세그먼트를 polygon으로 그림 (업비트 방식) // 미래 시점(26일 선행)까지 포함하여 렌더링 for (let i = 0; i < cloudData.length - 1; i++) { const curr = cloudData[i]; const next = cloudData[i + 1]; // 시간 좌표 변환 (미래 시점 포함) const x1 = timeScale.timeToCoordinate(curr.time as Time); const x2 = timeScale.timeToCoordinate(next.time as Time); if (x1 === null || x2 === null) continue; if (next.time <= curr.time) continue; if (Math.abs(x2 - x1) < 0.5) continue; // 화면 밖의 세그먼트는 스킵 (성능 최적화) // ✅ chartDrawWidth를 기준으로 판단 if ((x1 < -100 && x2 < -100) || (x1 > chartDrawWidth + 100 && x2 > chartDrawWidth + 100)) { continue; } // ✅ 유효성 검사: 0/NaN/Infinity 스킵 (수평 블록·캔들 가림 방지) const isValid = (v: number) => v != null && !isNaN(v) && isFinite(v) && v > 1e-10; if (!isValid(curr.senkouA) || !isValid(curr.senkouB) || !isValid(next.senkouA) || !isValid(next.senkouB)) { continue; } // ✅ 가격 범위 검증: 차트 가격 범위의 10배를 벗어나는 값은 비정상 데이터로 간주하고 스킵 const priceRangeExtended = (priceRange.max - priceRange.min) * 10; const minValidPrice = priceRange.min - priceRangeExtended; const maxValidPrice = priceRange.max + priceRangeExtended; const isPriceInRange = (v: number) => v >= minValidPrice && v <= maxValidPrice; if (!isPriceInRange(curr.senkouA) || !isPriceInRange(curr.senkouB) || !isPriceInRange(next.senkouA) || !isPriceInRange(next.senkouB)) { console.warn(`⚠️ [IchimokuCloud] 가격 범위 초과 스킵: senkou=[${curr.senkouA.toFixed(0)}, ${curr.senkouB.toFixed(0)}] → [${next.senkouA.toFixed(0)}, ${next.senkouB.toFixed(0)}], 유효범위=[${minValidPrice.toFixed(0)}, ${maxValidPrice.toFixed(0)}]`); continue; } // 가격을 Y좌표로 변환 (차트의 가격 축과 동기화) const y1A = priceToY(curr.senkouA); const y1B = priceToY(curr.senkouB); const y2A = priceToY(next.senkouA); const y2B = priceToY(next.senkouB); const sCross = senkouCrossFraction(curr.senkouA, curr.senkouB, next.senkouA, next.senkouB); if (sCross != null) { const xc = x1 + sCross * (x2 - x1); const pCross = curr.senkouA + sCross * (next.senkouA - curr.senkouA); const yc = priceToY(pCross); const bullLeft = curr.senkouA >= curr.senkouB; const bullRight = next.senkouA >= next.senkouB; ctx.fillStyle = bullLeft ? cloudBullishColor : cloudBearishColor; ctx.beginPath(); ctx.moveTo(x1, y1A); ctx.lineTo(xc, yc); ctx.lineTo(x1, y1B); ctx.closePath(); ctx.fill(); ctx.fillStyle = bullRight ? cloudBullishColor : cloudBearishColor; ctx.beginPath(); ctx.moveTo(xc, yc); ctx.lineTo(x2, y2A); ctx.lineTo(x2, y2B); ctx.closePath(); ctx.fill(); } else { const isBullish = curr.senkouA >= curr.senkouB; ctx.fillStyle = isBullish ? cloudBullishColor : cloudBearishColor; ctx.beginPath(); ctx.moveTo(x1, y1A); ctx.lineTo(x2, y2A); ctx.lineTo(x2, y2B); ctx.lineTo(x1, y1B); ctx.closePath(); ctx.fill(); } } // ✅ 선행스팬 선 그리기 (Canvas로 처리하여 lightweight-charts Line 시리즈 "Value is null" 회피) const drawLineSeries = (data: Array<{ time: number; value: number }>, color: string, lineWidth: number) => { if (data.length < 2) return; const isValid = (v: number) => v != null && !isNaN(v) && isFinite(v) && v > 1e-10; ctx.beginPath(); let first = true; for (let i = 0; i < data.length; i++) { const curr = data[i]; const x = timeScale.timeToCoordinate(curr.time as Time); if (x === null || !isValid(curr.value)) { first = true; // 갭 시 새 선분 시작 continue; } const y = priceToY(curr.value); if (first) { ctx.moveTo(x, y); first = false; } else { ctx.lineTo(x, y); } } ctx.strokeStyle = color; ctx.lineWidth = lineWidth; ctx.stroke(); }; if (hasSenkouA) drawLineSeries(senkouALineData, senkouAColor, senkouAWidth); if (hasSenkouB) drawLineSeries(senkouBLineData, senkouBColor, senkouBWidth); if (hasTenkan) drawLineSeries(tenkanLineData, tenkanColor, tenkanWidth); if (hasKijun) drawLineSeries(kijunLineData, kijunColor, kijunWidth); // ✅ 클리핑 영역 복원 ctx.restore(); console.log(`✅ [IchimokuCloud] 구름 렌더링 완료: ${cloudData.length}개 포인트, 선행A=${senkouALineData.length}, 선행B=${senkouBLineData.length}, 가격범위: ${priceRange.min.toFixed(0)} ~ ${priceRange.max.toFixed(0)}, 그리기 영역: ${chartDrawWidth}px (Y축 제외)`); console.log(`🎨 [IchimokuCloud] 구름 색상: 상승=${cloudBullishColor}, 하락=${cloudBearishColor}`); } catch (error) { console.error('❌ [IchimokuCloud] Canvas 그리기 오류:', error); } }, [chartRef, isDisposedRef, cloudData, priceRange, containerWidth, containerHeight, cloudBullishColor, cloudBearishColor, candleSeriesRef, yAxisScaleFactor, senkouALineData, senkouBLineData, senkouAColor, senkouBColor, senkouAWidth, senkouBWidth, tenkanLineData, kijunLineData, tenkanColor, kijunColor, tenkanWidth, kijunWidth]); // 초기 그리기 useEffect(() => { drawCloud(); }, [drawCloud]); // 차트 변경 시 다시 그리기 useEffect(() => { const chart = chartRef.current; if (!chart) return; const timeScale = chart.timeScale(); const handleVisibleRangeChange = () => { drawCloud(); }; timeScale.subscribeVisibleLogicalRangeChange(handleVisibleRangeChange); return () => { timeScale.unsubscribeVisibleLogicalRangeChange(handleVisibleRangeChange); }; }, [chartRef, drawCloud]); return ( ); }; /** * 단순 이동평균(SMA) 계산 함수 */ const calculateSMA = (data: { time: number; close: number }[], period: number): { time: number; value: number | null }[] => { const result: { time: number; value: number | null }[] = []; for (let i = 0; i < data.length; i++) { if (i < period - 1) { // 초기 기간에는 null 값 설정 result.push({ time: data[i].time, value: null, }); } else { let sum = 0; for (let j = 0; j < period; j++) { sum += data[i - j].close; } result.push({ time: data[i].time, value: sum / period, }); } } return result; }; /** * 지수 이동평균(EMA) 계산 함수 */ const calculateEMA = (data: { time: number; close: number }[], period: number): { time: number; value: number | null }[] => { const result: { time: number; value: number | null }[] = []; const multiplier = 2 / (period + 1); if (data.length < period) { // 데이터가 부족하면 모두 null로 반환 return data.map(d => ({ time: d.time, value: null })); } let ema = 0; for (let i = 0; i < data.length; i++) { if (i < period - 1) { // 초기 기간에는 null 값 설정 result.push({ time: data[i].time, value: null }); } else if (i === period - 1) { // 첫 EMA는 SMA로 시작 let sum = 0; for (let j = 0; j < period; j++) { sum += data[i - j].close; } ema = sum / period; result.push({ time: data[i].time, value: ema }); } else { // 이후는 EMA 공식 적용 ema = (data[i].close - ema) * multiplier + ema; result.push({ time: data[i].time, value: ema }); } } return result; }; /** * 차트 데이터 검증 함수 - null, undefined, NaN, Infinity 값 제거 */ const validateChartData = (data: { time: number; value: number | null }[]): { time: number; value: number }[] => { return data .filter((d): d is { time: number; value: number } => d !== null && d !== undefined && d.time !== null && d.time !== undefined && !isNaN(d.time) && isFinite(d.time) && d.time > 0 && d.value !== null && d.value !== undefined && !isNaN(d.value) && isFinite(d.value) ); }; /** Histogram/Volume 시리즈용: 유효한 time·value만 남기고 시간 오름차순 정렬, 동일 time은 마지막만 유지 (LWCharts Value is null 방지) */ const sanitizeHistogramSeriesData = ( bars: T[] ): T[] => { const bySec = new Map(); for (const b of bars) { if (!b || b.value === undefined || b.value === null) continue; const v = Number(b.value); if (!Number.isFinite(v)) continue; const rawT = b.time; const sec = typeof rawT === 'number' ? Math.floor(rawT) : Math.floor(Number(rawT)); if (!Number.isFinite(sec) || sec <= 0) continue; const color = typeof b.color === 'string' && b.color.length > 0 ? b.color : undefined; bySec.set(sec, { ...b, time: sec as Time, value: v, ...(color ? { color } : {}) } as T); } return Array.from(bySec.entries()) .sort((a, b) => a[0] - b[0]) .map(([, row]) => row); }; /** * 일목균형표 계산 함수 */ const calculateIchimoku = (data: { time: number; high: number; low: number; close: number }[], tenkanPeriod: number = 9, kijunPeriod: number = 26, senkouBPeriod: number = 52) => { const result: { time: number; tenkan?: number; kijun?: number; chikou?: number; senkouA?: number; senkouB?: number; }[] = []; const getHighLow = (start: number, end: number) => { let high = -Infinity; let low = Infinity; for (let i = start; i <= end; i++) { if (data[i].high > high) high = data[i].high; if (data[i].low < low) low = data[i].low; } return { high, low }; }; for (let i = 0; i < data.length; i++) { const item: any = { time: data[i].time }; // 전환선 (Tenkan-sen): (9일 최고가 + 9일 최저가) / 2 if (i >= tenkanPeriod - 1) { const { high, low } = getHighLow(i - tenkanPeriod + 1, i); item.tenkan = (high + low) / 2; } // 기준선 (Kijun-sen): (26일 최고가 + 26일 최저가) / 2 if (i >= kijunPeriod - 1) { const { high, low } = getHighLow(i - kijunPeriod + 1, i); item.kijun = (high + low) / 2; } // 후행스팬 (Chikou Span): 현재 종가를 26일 후행 item.chikou = data[i].close; // 선행스팬1 (Senkou Span A): (전환선 + 기준선) / 2, 26일 선행 if (i >= Math.max(tenkanPeriod, kijunPeriod) - 1) { const { high: tHigh, low: tLow } = getHighLow(i - tenkanPeriod + 1, i); const { high: kHigh, low: kLow } = getHighLow(i - kijunPeriod + 1, i); const tenkan = (tHigh + tLow) / 2; const kijun = (kHigh + kLow) / 2; item.senkouA = (tenkan + kijun) / 2; } // 선행스팬2 (Senkou Span B): (52일 최고가 + 52일 최저가) / 2, 26일 선행 if (i >= senkouBPeriod - 1) { const { high, low } = getHighLow(i - senkouBPeriod + 1, i); item.senkouB = (high + low) / 2; } result.push(item); } return result; }; export interface ChartHandle { setCrosshairPosition: (time: Time) => void; clearCrosshair: () => void; setVisibleRange: (from: number, to: number) => void; setVisibleLogicalRange: (from: number, to: number) => void; getVisibleRange: () => { from: number; to: number } | null; getVisibleLogicalRange: () => { from: number; to: number } | null; resize: () => void; fitContent: () => void; timeScale: () => { timeToCoordinate: (time: number) => number | null; coordinateToTime: (x: number) => number | null; }; priceScale: () => { priceToCoordinate: (price: number) => number | null; coordinateToPrice: (y: number) => number | null; }; // ✅ 스크롤/줌 이벤트 구독 (외부 차트 동기화용) subscribeVisibleLogicalRangeChange: (callback: (range: { from: number; to: number }) => void) => void; unsubscribeVisibleLogicalRangeChange: (callback: (range: { from: number; to: number }) => void) => void; // ✅ 알림 마커 추가 (매수/매도 신호 표시) addAlertMarker: (time: Time, signalType: 'BUY' | 'SELL') => void; clearAlertMarkers: () => void; // ✅ 실시간 캔들 업데이트 (WebSocket에서 사용) updateLastCandle: (candle: { open: number; high: number; low: number; close: number; volume?: number; timestamp?: number | string; time?: string | number }, isNewCandle?: boolean) => void; // ✅ 실시간 지표 업데이트 (WebSocket에서 사용 - 이동평균선, 볼린저밴드, 일목균형표 등) updateLastIndicators: (candle: CandleData, allCandles: CandleData[], allIndicatorData: IndicatorData[]) => void; } // 차트 툴 타입 정의 (ChartToolbar와 동일) export type ChartTool = | 'crosshair' | 'magnifier' | 'trendLine' | 'horizontalLine' | 'verticalLine' | 'rectangle' | 'fibonacci' | 'fibonacciTimezone' | 'fibonacciFan' | 'fibonacciCircle' | 'fibonacciWedge' | 'text' | 'brush' | 'measure' | 'diagonalLine' | 'angleLine' | 'parallelChannel'; export interface AlertMarker { time: Time; position: 'aboveBar' | 'belowBar'; color: string; shape: 'arrowUp' | 'arrowDown' | 'circle'; text: string; } export interface CandlestickChartProps { data: CandleData[]; indicatorData?: IndicatorData[]; height?: number; onTimeRangeChange?: (from: number, to: number) => void; onCrosshairMove?: (time: Time | null) => void; onVisibleRangeChange?: (from: number, to: number) => void; // 네비게이션 기능 onNavigate?: (direction: 'past' | 'future') => void; showLeftButton?: boolean; showRightButton?: boolean; isLoading?: boolean; // 알림 마커 alertMarkers?: AlertMarker[]; // 보조지표 오버레이 표시 여부 (기본값: false) showIndicatorOverlay?: boolean; // 보조지표 토글 버튼 표시 여부 (기본값: showIndicatorOverlay와 동일) showIndicatorToggle?: boolean; // 외부 크로스헤어 동기화 (보조지표 차트에서 호버 시) externalHoverTime?: Time | null; // 외부 크로스헤어 활성화 여부 (보조지표 호버 시 자체 크로스헤어 숨김) hideNativeCrosshair?: boolean; // X축 타임스케일 숨김 여부 (기본값: false - 보조지표가 있을 때 캔들차트의 X축 숨김) hideTimeScale?: boolean; // ✅ 보조지표(서브패널) 하단에 날짜 라벨 표시 여부 (기본값: true - 투자분석에서 마지막 보조지표에 날짜 표시, 멀티차트에서는 false로 비표시) showSubPanelTimeScale?: boolean; // 툴팁 표시 여부 (기본값: true) showTooltip?: boolean; // ✅ 매수/매도 마커 클릭 핸들러 onMarkerClick?: (timestamp: string, tradeType: 'BUY' | 'SELL') => void; // ✅ 활성 툴 (트레이딩뷰 모드) activeTool?: ChartTool | null; // ✅ 차트 클릭 핸들러 (피보나치 타임존 등) onChartClick?: (time: number) => void; // ✅ 추세 판단 MA 설정 (차트 하단 추세 표시바용) trendShortMA?: number; // 단기 MA (기본값: 10) trendMediumMA?: number; // 중기 MA (기본값: 20) trendLongMA?: number; // 장기 MA (기본값: 60) // ✅ 차트 마우스 이벤트 핸들러 (드래그 방식 드로잉) onChartMouseDown?: (time: number) => void; onChartMouseMove?: (time: number) => void; onChartMouseUp?: (time: number) => void; // ✅ 줌 레벨 변경 핸들러 (보조지표 차트 동기화용) onZoomChange?: (fromTime: number, toTime: number) => void; // ✅ 차트 뷰 모드 ('basic' | 'tradingview') chartViewMode?: 'basic' | 'tradingview'; // ✅ MA/Ichimoku 계산용 추가 데이터 (화면에는 표시하지 않고 계산에만 사용) maCalculationData?: CandleData[]; // ✅ 전체 400개 지표 데이터 (MA 렌더링용 maValues 포함) allIndicatorData?: IndicatorData[]; // ✅ 이동평균선 설정 maLines?: Array<{ id: string; enabled: boolean; period: number; color: string; width: number; type: 'SMA' | 'EMA'; }>; // ✅ 일목균형표 설정 ichimokuSettings?: { tenkan: boolean; kijun: boolean; chikou: boolean; senkouA: boolean; senkouB: boolean; tenkanPeriod: number; kijunPeriod: number; senkouBPeriod: number; }; // ✅ 일목균형표 색상 및 선 굵기 ichimokuColors?: { tenkan: string; kijun: string; chikou: string; senkouA: string; senkouB: string; cloudBullish?: string; // 상승 구름 색상 cloudBearish?: string; // 하락 구름 색상 tenkanWidth?: number; kijunWidth?: number; chikouWidth?: number; senkouAWidth?: number; senkouBWidth?: number; }; // ✅ 서브차트 패널 설정 (TradingView 스타일) subPanels?: Array<{ type: 'volume' | 'macd' | 'rsi' | 'stochastic' | 'cci' | 'adx' | 'obv' | 'newPsychological' | 'investPsychological' | 'bwi' | 'dmi' | 'williamsR' | 'momentum' | 'trix' | 'volumeOsc' | 'vr' | 'disparity'; height?: number; // 서브패널 높이 (기본값: 120px) enabled: boolean; }>; // ✅ 서브패널 타이틀 클릭 핸들러 (설정 화면 열기) onSubPanelSettingsClick?: (panelType: string, title: string) => void; // ✅ 보조지표 삭제 핸들러 (보조지표 off 설정) onIndicatorDelete?: (panelType: string) => void; // ✅ 보조지표 설정 (기준선 표시용) indicatorSettings?: any; // ✅ 차트 색상 설정 (기준선 색상용) colorSettings?: any; // ✅ 서브패널 순서 변경 핸들러 onSubPanelReorder?: (fromIndex: number, toIndex: number) => void; // ✅ 모바일 모드 플래그 (모바일 최적화 터치 설정 적용) isMobileMode?: boolean; // ✅ 외부에서 제어하는 시간 범위 (실시간 동기화) sharedTimeRange?: { from: number; to: number } | null; // ✅ 색상 버전 (변경 시 서브패널 강제 업데이트용) colorVersion?: number; // ✅ 설정 버전 (변경 시 서브패널 강제 업데이트용) settingsVersion?: number; // ✅ 리사이즈 중 플래그 (차트 갱신 방지) isResizing?: boolean; // ✅ 외부 강제 업데이트 카운터 (보조지표 실시간 갱신용) externalForceUpdate?: number; // ✅ 컴팩트 툴바 (멀티차트 등 작은 차트에서 버튼 크기 축소) compactToolbar?: boolean; // ✅ 보조지표 하단 줌/스크롤/리셋 툴바 숨김 (멀티차트 전용 - 캔들 아래에 이미 동일 툴바 있음) hideSubPanelToolbar?: boolean; // ✅ 캔들 위·보조지표 하단 줌/좌우이동/리셋 툴바 숨김 (멀티차트·알림목록 — 투자분석은 미전달 시 표시) hideNavigationToolbar?: boolean; // ✅ 캔들 하단 정배열/역배열 추세 바 숨김 (멀티차트·알림목록) hideTrendAlignmentBar?: boolean; // ✅ 차트 내 범례 숨김 (멀티차트 등) hideLegends?: boolean; // ✅ 멀티차트 등에서 context 대신 사용할 오버레이 설정 (볼린저 등) overlaySettingsOverride?: { bollingerUpper?: boolean; bollingerMiddle?: boolean; bollingerLower?: boolean; bollingerPeriod?: number; bollingerStdDev?: number; }; // ✅ 캔들 영역 높이 리사이즈 핸들 (캔들/보조지표 경계에 드래그 버튼) onCandleHeightResize?: (newHeight: number) => void; showCandleResizeHandle?: boolean; } // ✅ 드래그 가능한 서브패널 컴포넌트 interface SortableSubPanelProps { panel: { type: 'volume' | 'macd' | 'rsi' | 'stochastic' | 'cci' | 'adx' | 'obv' | 'newPsychological' | 'investPsychological' | 'bwi' | 'dmi' | 'williamsR' | 'momentum' | 'trix' | 'volumeOsc' | 'vr' | 'disparity'; height?: number; enabled: boolean; }; index: number; isEven: boolean; chartColors: any; themeMode: ThemeMode; subChartContainersRef: React.MutableRefObject<{ [key: string]: HTMLDivElement | null }>; onSubPanelSettingsClick?: (panelType: string, title: string) => void; onIndicatorDelete?: (panelType: string) => void; // ✅ 보조지표 삭제(off) 핸들러 showDragHandle: boolean; isMaximized?: boolean; onMaximize?: () => void; onRestore?: () => void; colorVersion?: number; // ✅ 색상 버전 settingsVersion?: number; // ✅ 설정 버전 } const SortableSubPanel: React.FC = ({ panel, index, isEven, chartColors, themeMode, subChartContainersRef, onSubPanelSettingsClick, onIndicatorDelete, showDragHandle, isMaximized = false, onMaximize, onRestore, colorVersion, settingsVersion, }) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: panel.type }); // ✅ 그래프 선 선택 상태 관리 const [isSelected, setIsSelected] = useState(false); // ✅ 툴박스 메뉴 앵커 const [toolboxAnchor, setToolboxAnchor] = useState(null); // ✅ 더보기 서브메뉴 앵커 const [moreMenuAnchor, setMoreMenuAnchor] = useState(null); const style = { transform: CSS.Transform.toString(transform), transition, }; const alternatingBgColor = themeMode === 'dark' ? (isEven ? 'rgba(255, 255, 255, 0.02)' : chartColors.background) : (isEven ? 'rgba(0, 0, 0, 0.02)' : chartColors.background); const titleMap: Record = { volume: '거래량', macd: 'MACD', rsi: 'RSI', stochastic: 'Stochastic Slow', cci: 'CCI', adx: 'ADX', obv: 'OBV', williamsR: 'Williams %R', trix: 'TRIX', disparity: '이격도', bwi: 'BWI', volumeOsc: 'VolumeOSC', vr: 'VR', newPsychological: '신심리도', investPsychological: '투자심리도', dmi: 'DMI', }; // ✅ 보조지표 설정값 가져오기 (프론트엔드) const { indicatorSettings, updateIndicatorSettings } = useIndicatorSettings(); // ✅ 백엔드 설정값 가져오기 const { backendSettings } = useUpbitData(); // ✅ 차트 색상 설정 가져오기 const { colors: colorSettings } = useChartColors(); // ✅ 디버그: 서브패널 렌더링 및 버전 변경 감지 console.log(`🎬 [SortableSubPanel:${panel.type}] 렌더링됨 - colorVersion: ${colorVersion}, settingsVersion: ${settingsVersion} [BUILD_V6]`); useEffect(() => { console.log(`🔔 [SortableSubPanel:${panel.type}] 버전 변경 감지 - colorVersion: ${colorVersion}, settingsVersion: ${settingsVersion} [BUILD_V6]`); }, [colorVersion, settingsVersion, panel.type]); // ✅ 보조지표별 설정값 텍스트 생성 (프론트엔드) const getSettingsText = (panelType: string): React.ReactNode => { if (!indicatorSettings || !colorSettings) return ''; switch (panelType) { case 'macd': { return ( <> {'('} {indicatorSettings.macdFastPeriod} {','} {indicatorSettings.macdSlowPeriod} {','} {indicatorSettings.macdSignalPeriod} {')'} ); } case 'stochastic': { return ( <> {'('} {indicatorSettings.stochasticKPeriod} {','} {indicatorSettings.stochasticDPeriod} {','} {indicatorSettings.stochasticSlowing} {')'} ); } case 'rsi': { return ( <> {'('} {indicatorSettings.rsiPeriod} {')'} ); } case 'cci': { const values: Array<{ value: number; color: string }> = []; values.push({ value: indicatorSettings.cciPeriod, color: colorSettings.cciColor }); if (indicatorSettings.cciSignalEnabled) { values.push({ value: indicatorSettings.cciSignalPeriod, color: colorSettings.cciSignalColor }); } return ( <> {values.map((v, idx) => ( {v.value}{idx < values.length - 1 ? ',' : ''} ))} ); } case 'adx': { return ( <> {'('} {indicatorSettings.adxPeriod} {')'} ); } case 'dmi': { return ( <> {'('} {indicatorSettings.dmiPeriod} {')'} ); } case 'williamsR': { return ( <> {'('} {indicatorSettings.williamsRPeriod} {')'} ); } case 'obv': { const values: Array<{ value: number; color: string }> = []; if (indicatorSettings.obvEnabled) { values.push({ value: indicatorSettings.obvPeriod, color: colorSettings.obvColor }); } if (indicatorSettings.obvSignalEnabled) { values.push({ value: indicatorSettings.obvSignalPeriod, color: colorSettings.obvSignalColor }); } if (values.length === 0) return ''; return ( <> {values.map((v, idx) => ( {v.value}{idx < values.length - 1 ? ',' : ''} ))} ); } case 'bwi': { return ( <> {'('} {indicatorSettings.bwiPeriod} {')'} ); } case 'trix': { return ( <> {'('} {indicatorSettings.trixPeriod} {','} {indicatorSettings.trixSignalPeriod} {')'} ); } case 'volumeOsc': { return ( <> {'('} {indicatorSettings.volumeOscShortPeriod} {','} {indicatorSettings.volumeOscLongPeriod} {')'} ); } case 'vr': { const values: Array<{ value: number; color: string }> = []; values.push({ value: indicatorSettings.vrPeriod, color: colorSettings.vrColor }); if (indicatorSettings.vr2Enabled) { values.push({ value: indicatorSettings.vr2Period, color: colorSettings.vr2Color }); } return ( <> {values.map((v, idx) => ( {v.value}{idx < values.length - 1 ? ',' : ''} ))} ); } case 'newPsychological': { return ( <> {'('} {indicatorSettings.newPsychologicalPeriod} {')'} ); } case 'investPsychological': { return ( <> {'('} {indicatorSettings.investPsychologicalPeriod} {')'} ); } case 'disparity': { const values: Array<{ value: number; color: string }> = []; if (indicatorSettings.disparityUltraShortEnabled) { values.push({ value: indicatorSettings.disparityUltraShortPeriod, color: colorSettings.disparityUltraShortColor }); } if (indicatorSettings.disparityShortEnabled) { values.push({ value: indicatorSettings.disparityShortPeriod, color: colorSettings.disparityShortColor }); } if (indicatorSettings.disparityMidEnabled) { values.push({ value: indicatorSettings.disparityMidPeriod, color: colorSettings.disparityMidColor }); } if (indicatorSettings.disparityLongEnabled) { values.push({ value: indicatorSettings.disparityLongPeriod, color: colorSettings.disparityLongColor }); } if (values.length === 0) return ''; return ( <> {values.map((v, idx) => ( {v.value}{idx < values.length - 1 ? ',' : ''} ))} ); } default: return ''; } }; // ✅ 툴박스 메뉴 핸들러 const handleToolboxClose = () => { setToolboxAnchor(null); setMoreMenuAnchor(null); }; const handleHideGraph = () => { setIsSelected(false); handleToolboxClose(); }; const handleSettings = () => { if (onSubPanelSettingsClick) { onSubPanelSettingsClick(panel.type, titleMap[panel.type] || panel.type.toUpperCase()); } handleToolboxClose(); }; const handleDelete = () => { // ✅ 보조지표 삭제(off 설정) if (onIndicatorDelete) { onIndicatorDelete(panel.type); } handleToolboxClose(); }; const handleMoreClick = (event: React.MouseEvent) => { setMoreMenuAnchor(event.currentTarget); }; const handleMoreClose = () => { setMoreMenuAnchor(null); }; // ✅ 최대화/복원 핸들러 const handleMaximizeToggle = (e: React.MouseEvent) => { e.stopPropagation(); if (isMaximized && onRestore) { onRestore(); } else if (!isMaximized && onMaximize) { onMaximize(); } }; return (
{ subChartContainersRef.current[panel.type] = el; }} onClick={() => { // ✅ 차트 영역 클릭 시 선택 상태 토글 setIsSelected(!isSelected); }} onDoubleClick={(e) => { // ✅ 차트 영역 더블클릭 시 설정 팝업 열기 e.stopPropagation(); if (onSubPanelSettingsClick) { onSubPanelSettingsClick(panel.type, titleMap[panel.type] || panel.type.toUpperCase()); } }} style={{ width: '100%', height: '100%', position: 'relative', cursor: 'pointer', boxSizing: 'border-box', }} /> {/* 서브차트 타이틀 및 백엔드 설정값 - flex로 나란히 배치 */} {/* 타이틀 버튼 - 파란색 칩 스타일 (업비트 스타일) */} { e.stopPropagation(); if (onSubPanelSettingsClick) { onSubPanelSettingsClick(panel.type, titleMap[panel.type] || panel.type.toUpperCase()); } }} sx={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 1, pl: 1.5, pr: 1.5, py: 0.3, borderRadius: 0.8, bgcolor: themeMode === 'dark' ? 'rgba(100, 100, 100, 0.5)' : 'rgba(200, 200, 200, 0.6)', '&:hover': { bgcolor: themeMode === 'dark' ? 'rgba(120, 120, 120, 0.6)' : 'rgba(180, 180, 180, 0.7)', }, transition: 'background-color 0.2s', border: '1px solid', borderColor: themeMode === 'dark' ? 'rgba(150, 150, 150, 0.3)' : 'rgba(160, 160, 160, 0.4)', boxShadow: '0 2px 4px rgba(0,0,0,0.2)', }} > {titleMap[panel.type] || panel.type.toUpperCase()} {typeof getSettingsText(panel.type) === 'string' ? ( {getSettingsText(panel.type)} ) : ( {getSettingsText(panel.type)} )} {/* 백엔드 설정값 표시 제거됨 */} {/* ✅ 우측 상단 삭제(X) 버튼 - 항상 표시 (멀티차트 등). 우측 격자 셀에서 짤림 방지용 패딩 확보 */} {onIndicatorDelete && ( { e.stopPropagation(); handleDelete(); }} sx={{ position: 'absolute', top: 4, right: 4, zIndex: 11, p: 0.5, bgcolor: themeMode === 'dark' ? 'rgba(60, 60, 60, 0.8)' : 'rgba(220, 220, 220, 0.8)', '&:hover': { bgcolor: themeMode === 'dark' ? 'rgba(100, 100, 100, 0.9)' : 'rgba(200, 200, 200, 0.95)', }, }} > )} {/* ✅ 툴박스 - 타이틀 우측에 표시 (선택 시에만) */} {isSelected && ( { e.stopPropagation(); handleHideGraph(); }} sx={{ p: 0.5 }} title="감추기" > { e.stopPropagation(); handleSettings(); }} sx={{ p: 0.5 }} title="설정" > { e.stopPropagation(); handleDelete(); }} sx={{ p: 0.5 }} title="삭제" > { e.stopPropagation(); handleMoreClick(e); }} sx={{ p: 0.5 }} title="더보기" > )} {/* ✅ 더보기 서브메뉴 */} { console.log('즐겨찾기 추가'); handleMoreClose(); }}> { console.log('보는차례'); handleMoreClose(); }}> { console.log('인터벌 가시성'); handleMoreClose(); }}> { console.log('스케일 고정'); handleMoreClose(); }}> { console.log('커피'); handleMoreClose(); }}> { handleHideGraph(); handleMoreClose(); }}> { handleDelete(); handleMoreClose(); }}> { console.log('오브젝트 트리'); handleMoreClose(); }}> { handleSettings(); handleMoreClose(); }}> {/* 우측 컨트롤 버튼 그룹 */} {showDragHandle && ( <> {isMaximized ? ( // 최대화 상태: 복원 버튼을 좌측 상단 타이틀 아래에 표시 ) : ( // 일반 상태: 최대화 버튼과 드래그 핸들을 좌측 상단 타이틀 아래에 표시 {/* 최대화 버튼 */} {/* 드래그 핸들 */} )} )} ); }; /** * 캔들스틱 차트 컴포넌트 * lightweight-charts v4를 사용 */ const CandlestickChart = forwardRef( ({ data, indicatorData, height = 400, onTimeRangeChange, onCrosshairMove, onVisibleRangeChange, onNavigate, showLeftButton = true, showRightButton = false, isLoading = false, showIndicatorOverlay = false, showIndicatorToggle, externalHoverTime, hideNativeCrosshair = false, hideTimeScale = false, showSubPanelTimeScale = true, showTooltip = true, onMarkerClick, activeTool = 'crosshair', onChartClick, trendShortMA = 10, trendMediumMA = 20, trendLongMA = 60, onChartMouseDown, onChartMouseMove, onChartMouseUp, onZoomChange, chartViewMode = 'basic', maCalculationData, allIndicatorData, maLines, ichimokuSettings, ichimokuColors, subPanels = [], onSubPanelSettingsClick, onIndicatorDelete, indicatorSettings, colorSettings, onSubPanelReorder, isMobileMode = false, sharedTimeRange, colorVersion, settingsVersion, alertMarkers = [], isResizing = false, externalForceUpdate = 0, compactToolbar = false, hideSubPanelToolbar = false, hideNavigationToolbar = false, hideTrendAlignmentBar = false, overlaySettingsOverride, hideLegends = false, onCandleHeightResize, showCandleResizeHandle = false }, ref) => { // ✅ 디버그: CandlestickChart가 받은 props 확인 console.log(`🎬 [CandlestickChart] 렌더링됨 - colorVersion: ${colorVersion}, settingsVersion: ${settingsVersion} [BUILD_V6]`); // ✅ 멀티차트 등에서 indicatorSettings prop 미전달 시 Context 사용 (가로 기준선 표시) const { indicatorSettings: contextIndicatorSettings } = useIndicatorSettings(); const effectiveIndicatorSettings = indicatorSettings ?? contextIndicatorSettings; // ✅ maLines, ichimokuSettings, ichimokuColors를 직렬화하여 안정적인 참조 생성 const maLinesKey = useMemo(() => JSON.stringify(maLines), [maLines]); const ichimokuSettingsKey = useMemo(() => JSON.stringify(ichimokuSettings), [ichimokuSettings]); const ichimokuColorsKey = useMemo(() => JSON.stringify(ichimokuColors), [ichimokuColors]); // ✅ effectiveIndicatorSettings 직렬화 - object 참조 변경 시 과도한 서브차트 effect 재실행 방지 const effectiveIndicatorSettingsKey = useMemo( () => (effectiveIndicatorSettings ? JSON.stringify(effectiveIndicatorSettings) : ''), [effectiveIndicatorSettings] ); const overlaySettingsOverrideKey = useMemo( () => JSON.stringify(overlaySettingsOverride ?? {}), [overlaySettingsOverride] ); /** 알림·내비 마커만 바뀔 때 메인 차트 effect가 재실행되지 않아 setMarkers가 스킵되는 것을 방지 */ const alertMarkersKey = useMemo(() => JSON.stringify(alertMarkers ?? []), [alertMarkers]); const dataForMarkersRef = useRef(data); dataForMarkersRef.current = data; /** * 투자분석: 백테스트/투자실행 후 isBuyPoint·isSellPoint만 바뀌고 length·첫봉 time은 그대로인 경우가 많음. * 메인 차트 effect가 재실행되지 않아 setMarkers가 스킵되던 문제를 이 키로 감지한다 (실시간 OHLC만 바뀌면 키 불변). */ const dataTradeMarkerSyncKey = useMemo(() => { const parts: string[] = []; for (let i = 0; i < data.length; i++) { const d = data[i] as CandleData; if (d.isBuyPoint) parts.push(`B:${String(d.time)}`); if (d.isSellPoint) parts.push(`S:${String(d.time)}`); } return parts.join('|'); }, [data]); /** 줌/스크롤 시 updateTradeMarkerPositions가 최신 매수·매도 시점을 읽도록 ref 유지 */ const tradeMarkerOverlaySpecsRef = useRef>([]); const uniqueDataForTradeOverlayRef = useRef([]); // showIndicatorToggle이 명시적으로 설정되지 않으면 showIndicatorOverlay 값을 따름 const shouldShowToggle = showIndicatorToggle !== undefined ? showIndicatorToggle : showIndicatorOverlay; const chartContainerRef = useRef(null); const chartRef = useRef(null); const candleSeriesRef = useRef | null>(null); // ✅ 서브차트 refs (TradingView 스타일) const subChartContainersRef = useRef<{ [key: string]: HTMLDivElement | null }>({}); const subChartsRef = useRef<{ [key: string]: IChartApi | null }>({}); const subSeriesRef = useRef<{ [key: string]: any }>({}); /** 선행스팬 ON일 때는 메인(플레이스홀더 봉)·서브(실봉만) 논리봉 수가 달라 “시간 기준” 동기화가 필수. 구독 핸들러·setTimeout은 effect 클로저보다 매 렌더 갱신 ref로 최신 모드를 읽는다 */ const senkouSpanActiveForSubSyncRef = useRef(false); senkouSpanActiveForSubSyncRef.current = !!(ichimokuSettings?.senkouA || ichimokuSettings?.senkouB); const isSettingRangeRef = useRef(false); const isSettingExternalCrosshairRef = useRef(false); // 외부 크로스헤어 설정 중 플래그 // ✅ 실시간 업데이트용: 이동평균선, 볼린저밴드 시리즈 저장 const maSeriesMapRef = useRef>>(new Map()); const bollingerSeriesRef = useRef<{ upper?: ISeriesApi<'Line'>; middle?: ISeriesApi<'Line'>; lower?: ISeriesApi<'Line'>; }>({}); const mainChartTimeRangeRef = useRef<{ from: Time; to: Time } | null>(null); // ✅ 메인 차트 시간 범위 저장 // ✅ 마우스 휠 줌 기능 제거 (하단 슬라이더로 대체) const markersRef = useRef([]); // ✅ 마커 데이터를 ref로 저장 (줌인/줌아웃 시 유지) const activeToolRef = useRef(activeTool); // ✅ 업비트 스타일 크로스헤어: 전역 수직선 + 개별 차트 수평선 const [globalCrosshairX, setGlobalCrosshairX] = useState(null); const [activeCrosshairChart, setActiveCrosshairChart] = useState(null); // 'main' or panel.type const chartMainContainerRef = useRef(null); // 전체 차트 영역 ref // ✅ 차트 내비게이션 툴바 표시 여부 (모바일에서는 숨김) const [showNavigationToolbar, setShowNavigationToolbar] = useState(!isMobileMode); // ✅ 보조지표 최대화 상태 관리 const [maximizedPanel, setMaximizedPanel] = useState(null); // ✅ 캔들 영역 리사이즈 중 상태 (드래그 핸들 시각 피드백) const [isCandleResizing, setIsCandleResizing] = useState(false); // ✅ 드래그 중 상태 관리 (차트 갱신 방지용) - ref로 관리하여 useEffect dependency 문제 방지 const isDraggingRef = useRef(false); const isResizingRef = useRef(false); // ✅ @dnd-kit 센서 설정 (드래그 앤 드롭) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, // 8px 이상 움직여야 드래그 시작 (클릭과 구분) }, }) ); // ✅ 활성 툴 ref const lastClickTimeRef = useRef<{ time: number; timestamp: string; tradeType: 'BUY' | 'SELL' } | null>(null); // ✅ 더블클릭 감지용 const isChartReadyRef = useRef(false); // ✅ 차트 준비 완료 플래그 const pendingCrosshairRef = useRef