- {/* 추세 구간 표시 바 (정배열/역배열/혼조) */}
- {(() => {
- // ✅ 모바일 모드에서는 추세 바 숨김
- if (isMobileMode) return null;
- if (!indicatorData || indicatorData.length === 0) return null;
-
- try {
- const chart = chartRef.current!;
- const timeScale = chart.timeScale();
- const visibleRange = timeScale.getVisibleRange();
-
- if (!visibleRange) return null;
-
- const visibleFrom = visibleRange.from as number;
- const visibleTo = visibleRange.to as number;
-
- // 보이는 범위의 데이터 필터
- const visibleData = indicatorData.filter(ind => {
- const time = new Date(ind.timestamp).getTime() / 1000;
- return time >= visibleFrom && time <= visibleTo;
- });
-
- // 필터링 실패하면 전체 데이터 사용
- const dataToUse = visibleData.length > 0 ? visibleData : indicatorData;
-
- // 트렌드 바 생성
- const bars: JSX.Element[] = [];
-
- for (let i = 0; i < dataToUse.length; i++) {
- const ind = dataToUse[i];
- const time = new Date(ind.timestamp).getTime() / 1000;
-
- // MA 값 (sma로 접근)
- const maShort = (ind as any)[`sma${trendShortMA}`];
- const maMedium = (ind as any)[`sma${trendMediumMA}`];
- const maLong = (ind as any)[`sma${trendLongMA}`];
-
- // 트렌드 판단
- let color = 'rgba(158, 158, 158, 0.5)'; // 기본: 혼조 (회색)
- let trendType = '혼조';
-
- if (maShort && maMedium && maLong) {
- if (maShort > maMedium && maMedium > maLong) {
- color = 'rgba(76, 175, 80, 0.75)'; // 정배열 (초록)
- trendType = '정배열';
- } else if (maShort < maMedium && maMedium < maLong) {
- color = 'rgba(244, 67, 54, 0.75)'; // 역배열 (빨강)
- trendType = '역배열';
- }
- }
-
- // 좌표 변환
- const x = timeScale.timeToCoordinate(time as Time);
- if (x === null) continue;
-
- // 너비 계산
- let width = 8;
- if (i < dataToUse.length - 1) {
- const nextTime = new Date(dataToUse[i + 1].timestamp).getTime() / 1000;
- const nextX = timeScale.timeToCoordinate(nextTime as Time);
- if (nextX !== null) {
- width = nextX - x;
- }
- }
-
- bars.push(
- {
- setTrendDetailModal({
- visible: true,
- timestamp: ind.timestamp,
- maShort: maShort || 0,
- maMedium: maMedium || 0,
- maLong: maLong || 0,
- trendType: trendType as '정배열' | '역배열' | '혼조',
- allMaValues: ind,
- });
- }}
- />
- );
- }
-
- if (bars.length === 0) return null;
-
- return (
-
- {bars}
-
- );
- } catch (error) {
- console.error('트렌드 바 렌더링 오류:', error);
- return null;
- }
- })()}
-
- )}
-
- {/* ✅ 서브차트 패널들 (TradingView 스타일) - @dnd-kit 드래그 앤 드롭 */}
- {subPanels && subPanels.length > 0 && subPanels.some(p => p.enabled) && (
- {
- const { active, over } = event;
- if (over && active.id !== over.id && onSubPanelReorder) {
- // ✅ 전체 subPanels 배열에서 실제 인덱스를 찾아야 함
- const oldIndex = subPanels.findIndex(p => p.type === active.id);
- const newIndex = subPanels.findIndex(p => p.type === over.id);
-
- console.log(`🔄 [SubPanel Reorder] ${active.id} (index: ${oldIndex}) → ${over.id} (index: ${newIndex})`);
-
- if (oldIndex !== -1 && newIndex !== -1) {
- onSubPanelReorder(oldIndex, newIndex);
- }
- }
- }}
- >
- p.enabled).map(p => p.type)}
- strategy={verticalListSortingStrategy}
- >
-
- {subPanels.filter(p => p.enabled).map((panel, index) => {
- const isEven = index % 2 === 0;
- const isMaximized = maximizedPanel === panel.type;
- const shouldHide = maximizedPanel && !isMaximized;
-
- return (
-
- p.enabled).length > 1}
- isMaximized={isMaximized}
- onMaximize={() => setMaximizedPanel(panel.type)}
- onRestore={() => setMaximizedPanel(null)}
- colorVersion={colorVersion}
- settingsVersion={settingsVersion}
- />
-
- );
- })}
-
- {/* ✅ 보조지표 하단 툴바 (최대화 시 숨김, 모바일에서도 숨김) */}
- {showNavigationToolbar && !maximizedPanel && !isMobileMode && (
-
-
- {/* 작게보기 */}
-
-
-
-
-
-
- {/* 크게보기 */}
-
-
-
-
-
-
- {/* 좌로 스크롤 */}
-
-
-
-
-
-
- {/* 우로 스크롤 */}
-
-
-
-
-
-
- {/* 재설정 */}
-
-
-
-
-
-
-
- )}
-
-
-
- )}
-
-
- );
- }
-);
-
-CandlestickChart.displayName = 'CandlestickChart';
-
-// React.memo로 감싸서 불필요한 리렌더링 방지
-export default React.memo(CandlestickChart, (prevProps, nextProps) => {
- // 데이터 길이와 마지막 항목을 비교하여 변경 여부 판단
- if (prevProps.data.length !== nextProps.data.length) {
- return false; // 길이가 다르면 리렌더링
- }
-
- const prevLast = prevProps.data[prevProps.data.length - 1];
- const nextLast = nextProps.data[nextProps.data.length - 1];
-
- // 마지막 데이터의 time과 close 값이 같으면 리렌더링 스킵
- if (prevLast?.time !== nextLast?.time || prevLast?.close !== nextLast?.close) {
- return false; // 데이터가 변경되면 리렌더링
- }
-
- // indicatorData 길이 비교
- const prevIndLen = prevProps.indicatorData?.length || 0;
- const nextIndLen = nextProps.indicatorData?.length || 0;
- if (prevIndLen !== nextIndLen) {
- return false;
- }
-
- // externalHoverTime이 변경되면 리렌더링 (크로스헤어 동기화)
- if (prevProps.externalHoverTime !== nextProps.externalHoverTime) {
- return false;
- }
-
- // sharedTimeRange가 변경되면 리렌더링
- if (JSON.stringify(prevProps.sharedTimeRange) !== JSON.stringify(nextProps.sharedTimeRange)) {
- return false;
- }
-
- // height, isLoading 등 주요 props 변경 확인
- if (prevProps.height !== nextProps.height ||
- prevProps.isLoading !== nextProps.isLoading ||
- prevProps.showLeftButton !== nextProps.showLeftButton ||
- prevProps.showRightButton !== nextProps.showRightButton ||
- prevProps.activeTool !== nextProps.activeTool ||
- prevProps.chartViewMode !== nextProps.chartViewMode) {
- return false;
- }
-
- // maLines 비교
- if (JSON.stringify(prevProps.maLines) !== JSON.stringify(nextProps.maLines)) {
- return false;
- }
-
- // ichimokuSettings 비교
- if (JSON.stringify(prevProps.ichimokuSettings) !== JSON.stringify(nextProps.ichimokuSettings)) {
- return false;
- }
-
- // subPanels 비교
- if (JSON.stringify(prevProps.subPanels) !== JSON.stringify(nextProps.subPanels)) {
- return false;
- }
-
- // indicatorSettings 비교
- if (JSON.stringify(prevProps.indicatorSettings) !== JSON.stringify(nextProps.indicatorSettings)) {
- return false;
- }
-
- // ✅ colorVersion 비교 (색상 설정 변경)
- if (prevProps.colorVersion !== nextProps.colorVersion) {
- console.log(`🔄 [CandlestickChart memo] colorVersion 변경 감지: ${prevProps.colorVersion} → ${nextProps.colorVersion} [BUILD_V6]`);
- return false;
- }
-
- // ✅ settingsVersion 비교 (보조지표 설정 변경)
- if (prevProps.settingsVersion !== nextProps.settingsVersion) {
- console.log(`🔄 [CandlestickChart memo] settingsVersion 변경 감지: ${prevProps.settingsVersion} → ${nextProps.settingsVersion} [BUILD_V6]`);
- return false;
- }
-
- return true; // 모든 체크를 통과하면 리렌더링 스킵
-});
diff --git a/frontend_golden/src/components/CandlestickChartV5Full.tsx b/frontend_golden/src/components/CandlestickChartV5Full.tsx
deleted file mode 100644
index dd0560f..0000000
--- a/frontend_golden/src/components/CandlestickChartV5Full.tsx
+++ /dev/null
@@ -1,12546 +0,0 @@
-/* eslint-disable @typescript-eslint/no-redeclare */
-import React, { useEffect, useRef, useState, forwardRef, useImperativeHandle, useMemo, useCallback } from 'react';
-import { createChart, ColorType, IChartApi, ISeriesApi, Time } from '../utils/chartV5/lwcShim';
-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