goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
+783
View File
@@ -0,0 +1,783 @@
/**
* ChartSlot
* 레이아웃 내 개별 차트 슬롯. 자체 심볼/타임프레임/데이터를 관리한다.
*/
import React, {
useState, useEffect, useCallback, useRef, useMemo,
useImperativeHandle, forwardRef,
} from 'react';
import ReactDOM from 'react-dom';
import TradingChart from './TradingChart';
import IndicatorContextToolbar from './IndicatorContextToolbar';
import IndicatorSettingsModal from './IndicatorSettingsModal';
import MainChartSettingsModal from './MainChartSettingsModal';
import { MarketSearchPanel } from './MarketSearchPanel';
import { generateBars, formatPrice } from '../utils/dataGenerator';
import { getKoreanName } from '../utils/marketNameCache';
import { DEFAULT_MAIN_CHART_STYLE } from '../utils/storage';
import { enrichIndicatorConfig, getIndicatorDef } from '../utils/indicatorRegistry';
import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from '../utils/smaConfig';
import { normalizeIchimokuConfig } from '../utils/ichimokuConfig';
import { isUpbitMarket } from '../utils/upbitApi';
import { useChartRealtimeData, type WsStatus } from '../hooks/useChartRealtimeData';
import { invalidateMarketCache } from '../utils/requestCache';
import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader';
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
import {
duplicateIndicatorInList,
mergeIndicators,
reorderIndicatorGroup,
splitIndicatorPane,
} from '../utils/indicatorPaneMerge';
import { saveSlot } from '../utils/backendApi';
import type {
OHLCVBar, ChartType, Theme, Timeframe,
IndicatorConfig, LegendData, Drawing, ChartMode, MainChartStyle,
} from '../types';
import type { ChartManager } from '../utils/ChartManager';
// ── 타임프레임 옵션 ────────────────────────────────────────────────────────
const TF_OPTIONS: Timeframe[] = ['1m','3m','5m','15m','30m','1h','4h','1D','1W','1M'];
// ── Ws 배지 ───────────────────────────────────────────────────────────────
const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isUpbit }) => {
if (!isUpbit) return null;
const map: Record<WsStatus, { dot: string; label: string }> = {
connecting: { dot: 'dot-yellow', label: '연결 중...' },
connected: { dot: 'dot-lime', label: '실시간' },
disconnected: { dot: 'dot-red', label: '재연결' },
error: { dot: 'dot-red', label: '오류' },
};
const { dot, label } = map[status];
return (
<span className={`ws-badge ${dot}`} style={{ fontSize: '10px' }}>
<span className="ws-dot" />{label}
</span>
);
};
let _slotIndCounter = 0;
function newIndId() { return `sind_${++_slotIndCounter}_${Date.now()}`; }
// ── Ref 핸들 (외부에서 슬롯 조작용) ─────────────────────────────────────────
export interface ChartSlotHandle {
/** 지표 추가 */
addIndicator: (type: string, fromConfig?: IndicatorConfig) => void;
/** 지표 제거 (type 기준) */
removeIndicatorByType: (type: string) => void;
/** 보조지표 pane 복사 */
duplicateIndicator: (sourceId: string) => void;
/** 현재 활성 심볼 */
getSymbol: () => string;
/** 현재 활성 타임프레임 */
getTimeframe: () => Timeframe;
/** 현재 활성 지표 목록 */
getIndicators: () => IndicatorConfig[];
/** 심볼 변경 */
setSymbol: (s: string) => void;
/** 타임프레임 변경 */
setTimeframe: (tf: Timeframe) => void;
/** 지표 목록 교체 (모드 전환 시 이전 설정 복원용) */
setIndicators: (inds: IndicatorConfig[]) => void;
/** 크로스헤어 자석모드 설정 */
setCrosshairMagnet: (enabled: boolean) => void;
/** 가격축 라벨·설명 on/off */
setSeriesPriceLabelsEnabled: (enabled: boolean) => void;
/** 거래량 pane on/off */
setVolumeVisible: (visible: boolean) => void;
}
// ── Props ─────────────────────────────────────────────────────────────────
export interface ChartSlotProps {
slotIndex: number;
theme: Theme;
/** 슬롯이 활성(포커스) 상태인지 */
active: boolean;
onActivate: () => void;
/** 외부에서 강제 심볼 동기화 */
forcedSymbol?: string;
/** 외부에서 강제 타임프레임 동기화 */
forcedTf?: Timeframe;
/** 마운트 시 1회만 적용되는 초기 심볼 (단일↔다중 모드 전환 시 설정 이전용) */
initialSymbol?: string;
/** 마운트 시 1회만 적용되는 초기 타임프레임 */
initialTimeframe?: Timeframe;
/** 마운트 시 1회만 적용되는 초기 지표 목록 */
initialIndicators?: IndicatorConfig[];
/** Crosshair sync: 동기화 시각 (unix seconds) */
syncTime?: number;
onCrosshairTime?: (t: number) => void;
/** Time sync: 동기화 가시 시간 범위 */
syncVisibleRange?: { from: number; to: number } | null;
onVisibleRangeChange?: (r: { from: number; to: number }) => void;
/** Date range sync: 동기화 가시 논리 범위 */
syncLogicalRange?: { from: number; to: number } | null;
onLogicalRangeChange?: (r: { from: number; to: number }) => void;
/** 돋보기 활성 여부 (App 에서 전달) */
magnifierEnabled?: boolean;
/** 돋보기 닫기 콜백 */
onMagnifierClose?: () => void;
/** 멀티차트 전체 보기: 이 슬롯을 단일 차트로 확장 */
onFullView?: (symbol: string) => void;
/**
* 슬롯 내 지표 목록이 바뀔 때마다 호출.
* App 에서 활성 슬롯의 지표 목록을 IndicatorModal 에 반영하기 위해 사용.
*/
onIndicatorsChange?: (indicators: IndicatorConfig[]) => void;
/**
* 슬롯 내 심볼이 바뀔 때마다 호출.
* App 에서 activeSlotSymbol(호가창 심볼)을 동기화하기 위해 사용.
*/
onSymbolChange?: (symbol: string) => void;
/** 자석모드 상태 (크로스헤어 스냅) */
magnetMode?: 'off' | 'weak' | 'strong';
/** 차트 우클릭 → 매수·매도 패널 자동 입력 */
onTradeOrderRequest?: (req: { market: string; price: number; side: 'buy' | 'sell' }, slotIndex: number) => void;
/** 보조지표 우측 가격축 라벨·설명 표시 */
chartSeriesPriceLabels?: boolean;
/** 차트 하단 거래량 바 표시 */
chartVolumeVisible?: boolean;
chartRealtimeSource?: 'BACKEND_STOMP' | 'UPBIT_DIRECT';
displayTimezone?: string;
}
const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot({
slotIndex, theme, active, onActivate,
forcedSymbol, forcedTf,
initialSymbol, initialTimeframe, initialIndicators,
syncTime, onCrosshairTime,
syncVisibleRange, onVisibleRangeChange,
syncLogicalRange, onLogicalRangeChange,
onFullView,
magnifierEnabled = false,
onMagnifierClose,
onIndicatorsChange,
onSymbolChange,
magnetMode = 'off',
onTradeOrderRequest,
chartSeriesPriceLabels = true,
chartVolumeVisible = true,
chartRealtimeSource = 'BACKEND_STOMP',
displayTimezone,
}, ref) {
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings();
// ── 자체 상태 ─────────────────────────────────────────────────────────
// initial* 값이 있으면 우선 적용 (단일↔다중 모드 전환 시 설정 이전용)
const [symbol, setSymbol] = useState(initialSymbol ?? forcedSymbol ?? 'KRW-BTC');
const [timeframe, setTimeframe] = useState<Timeframe>(initialTimeframe ?? forcedTf ?? '1D');
const [chartType] = useState<ChartType>('candlestick');
const [indicators, setIndicators] = useState<IndicatorConfig[]>(initialIndicators ?? []);
const [drawings] = useState<Drawing[]>([]);
const [legend, setLegend] = useState<LegendData | null>(null);
const [ctxToolbar, setCtxToolbar] = useState<{ entryId: string; screenX: number; screenY: number } | null>(null);
const [settingsModalId, setSettingsModalId] = useState<string | null>(null);
const [showMainChartModal, setShowMainChartModal] = useState(false);
const [mainChartStyle, setMainChartStyle] = useState<MainChartStyle>(DEFAULT_MAIN_CHART_STYLE);
const [showMarket, setShowMarket] = useState(false);
// 슬롯 컨테이너 rect: MarketSearchPanel 위치 계산에 사용
const [slotAnchorRect, setSlotAnchorRect] = useState<DOMRect | null>(null);
const managerRef = useRef<ChartManager | null>(null);
// initialSymbol 도 초기값으로 포함: requestAnimationFrame 이전에 올바른 값 보장
const symbolRef = useRef(initialSymbol ?? forcedSymbol ?? 'KRW-BTC');
const timeframeRef = useRef<Timeframe>(forcedTf ?? '1D');
// symbol / timeframe 변경 시 ref 동기화 (외부 접근용)
useEffect(() => { symbolRef.current = symbol; }, [symbol]);
useEffect(() => { timeframeRef.current = timeframe; }, [timeframe]);
// 심볼 변경 시 부모(App)에 알림 → 호가창 activeSlotSymbol 동기화
const onSymbolChangeRef = useRef(onSymbolChange);
onSymbolChangeRef.current = onSymbolChange;
useEffect(() => {
onSymbolChangeRef.current?.(symbol);
}, [symbol]);
// 지표 목록 변경 시 부모(App)에 알림 → IndicatorModal 의 activeIndicators 갱신
const onIndicatorsChangeRef = useRef(onIndicatorsChange);
onIndicatorsChangeRef.current = onIndicatorsChange;
useEffect(() => {
onIndicatorsChangeRef.current?.(indicators);
}, [indicators]);
// indicators 를 ref 로도 유지 (useImperativeHandle 의 최신 값 접근용)
const indicatorsRef = useRef<IndicatorConfig[]>(indicators);
useEffect(() => { indicatorsRef.current = indicators; }, [indicators]);
// ── 외부 핸들 노출 ─────────────────────────────────────────────────────
useImperativeHandle(ref, () => ({
addIndicator: (type: string, fromConfig?: IndicatorConfig) => {
setIndicators(prev => {
if (prev.some(i => i.type === type)) return prev;
const def = getIndicatorDef(type);
if (!def) return prev;
let cfg: IndicatorConfig;
if (fromConfig) {
cfg = { ...fromConfig, id: newIndId(), type: def.type };
} else {
const params = getParams(type, def.defaultParams);
const { plots, hlines, cloudColors } = getVisualConfig(type, def.plots, def.hlines);
cfg = {
id: newIndId(),
type: def.type,
params,
plots,
hlines,
...(cloudColors ? { cloudColors } : {}),
};
}
if (type === 'SMA') {
cfg = normalizeSmaConfig({
...cfg,
plotVisibility: cfg.plotVisibility ?? createDefaultSmaPlotVisibility(),
});
} else if (type === 'IchimokuCloud') {
cfg = normalizeIchimokuConfig(cfg);
}
return [...prev, cfg];
});
},
removeIndicatorByType: (type: string) => {
setIndicators(prev => prev.filter(i => i.type !== type));
},
duplicateIndicator: (sourceId: string) => {
setIndicators(prev => duplicateIndicatorInList(sourceId, prev, newIndId) ?? prev);
},
getSymbol: () => symbolRef.current,
getTimeframe: () => timeframeRef.current,
getIndicators: () => indicatorsRef.current,
setSymbol: (s: string) => {
// 새 종목의 캐시를 미리 무효화해 신선한 데이터를 요청하도록 함
if (isUpbitMarket(s)) invalidateMarketCache(s);
setSymbol(s);
setLegend(null);
},
setTimeframe: (tf: Timeframe) => { setTimeframe(tf); },
setIndicators: (inds: IndicatorConfig[]) => { setIndicators(inds); },
setCrosshairMagnet: (enabled: boolean) => {
managerRef.current?.setCrosshairMagnet(enabled);
},
setSeriesPriceLabelsEnabled: (enabled: boolean) => {
managerRef.current?.setSeriesPriceLabelsEnabled(enabled);
},
setVolumeVisible: (visible: boolean) => {
managerRef.current?.setVolumeVisible(visible);
},
}), []);
// 자석모드 변경 → ChartManager 크로스헤어 모드 즉시 반영
useEffect(() => {
managerRef.current?.setCrosshairMagnet(magnetMode !== 'off');
}, [magnetMode]);
useEffect(() => {
managerRef.current?.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels);
}, [chartSeriesPriceLabels]);
useEffect(() => {
managerRef.current?.setVolumeVisible(chartVolumeVisible);
}, [chartVolumeVisible]);
// 외부 심볼 동기화
useEffect(() => {
if (forcedSymbol && forcedSymbol !== symbol) setSymbol(forcedSymbol);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [forcedSymbol]);
// 외부 타임프레임 동기화
useEffect(() => {
if (forcedTf && forcedTf !== timeframe) setTimeframe(forcedTf);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [forcedTf]);
// mainChartStyle → ChartManager
useEffect(() => {
managerRef.current?.updateMainSeriesStyle(mainChartStyle);
}, [mainChartStyle]);
// ── 슬롯 상태 변경 시 DB 자동 저장 (디바운스 2s) ─────────────────────────
// 멀티슬롯 모드에서 각 슬롯 독립 저장 보장 (App.tsx workspaceState는 슬롯 0만 포함)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
saveSlot(slotIndex, {
slotIndex,
symbol,
timeframe,
chartType: 'candlestick',
theme,
mode: 'normal',
logScale: false,
drawingsLocked: false,
drawingsVisible: true,
indicators,
drawings: [],
paneLayout: null,
mainChartStyle,
}).catch(err => console.error('[ChartSlot] saveSlot failed', err));
}, 2000);
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [slotIndex, symbol, timeframe, theme, indicators, mainChartStyle]);
// ── 동기화 콜백 refs (항상 최신 값을 참조하도록) ─────────────────────────
const onVisibleRangeChangeRef = useRef(onVisibleRangeChange);
const onLogicalRangeChangeRef = useRef(onLogicalRangeChange);
onVisibleRangeChangeRef.current = onVisibleRangeChange;
onLogicalRangeChangeRef.current = onLogicalRangeChange;
// 외부에서 sync 값을 적용하는 중임을 표시 (feedback loop 방지)
const isSyncingTimeRef = useRef(false);
const isSyncingLogicalRef = useRef(false);
// 데이터 로드 전 수신한 sync range 를 저장해두는 pending ref
// → onDataLoaded 콜백에서 재적용 (멀티차트 첫 마운트 시 타이밍 문제 해결)
const pendingSyncVisibleRef = useRef<{ from: number; to: number } | null>(null);
const pendingSyncLogicalRef = useRef<{ from: number; to: number } | null>(null);
// ── Crosshair sync: syncTime → setCrosshairAtTime ────────────────────
useEffect(() => {
if (!syncTime) { managerRef.current?.clearCrosshairPosition(); return; }
managerRef.current?.setCrosshairAtTime(syncTime);
}, [syncTime]);
// ── Time sync: syncVisibleRange 외부 적용 ────────────────────────────
useEffect(() => {
if (!syncVisibleRange) return;
const mgr = managerRef.current;
if (!mgr || mgr.getRawBarsLength() === 0) {
// 데이터 로드 전 → pending 저장 후 onDataLoaded 에서 재적용
pendingSyncVisibleRef.current = syncVisibleRange;
return;
}
isSyncingTimeRef.current = true;
mgr.applyVisibleTimeRange(syncVisibleRange.from, syncVisibleRange.to);
requestAnimationFrame(() => { isSyncingTimeRef.current = false; });
}, [syncVisibleRange]);
// ── Date range sync: syncLogicalRange 외부 적용 ──────────────────────
useEffect(() => {
if (!syncLogicalRange) return;
const mgr = managerRef.current;
if (!mgr || mgr.getRawBarsLength() === 0) {
pendingSyncLogicalRef.current = syncLogicalRange;
return;
}
isSyncingLogicalRef.current = true;
mgr.applyVisibleLogicalRange(syncLogicalRange.from, syncLogicalRange.to);
requestAnimationFrame(() => { isSyncingLogicalRef.current = false; });
}, [syncLogicalRange]);
// 멀티 레이아웃 새로고침 시 차트 빈 화면 방지:
// CSS Grid 높이 확정 지연에 대비해 여러 시점에 pane 높이를 재적용한다.
// TradingChart 자체도 applyPaneLayout 자동 재시도를 하지만, 슬롯 레벨에서도 보강.
// ※ TradingChart를 재마운트(key 변경)하면 managerRef가 초기화되어 MarketSearchPanel 등
// 다른 상태에 영향을 줄 수 있으므로, 재마운트 대신 resetPaneHeights 재시도만 수행한다.
const slotContainerRef = useRef<HTMLDivElement | null>(null);
// 데이터 로딩 완료 후 차트가 여전히 비어 있으면 1회만 재마운트 (data + blank guard)
const [reloadTick, setReloadTick] = useState(0);
const reloadTriggeredRef = useRef(false); // 재마운트는 최대 1회
useEffect(() => {
const checks = [200, 500, 1000, 2000].map(delay =>
setTimeout(() => {
const el = slotContainerRef.current;
const mgr = managerRef.current;
if (!el || !mgr) return;
const h = el.clientHeight;
if (h <= 0) return;
if (mgr.hasMainSeries()) {
// 차트 데이터 있음 → pane 높이만 재적용
mgr.resetPaneHeights(h);
} else if (!reloadTriggeredRef.current) {
// 차트 비어 있고 아직 재시도 안 했음 → 1회만 재마운트
// (bars 로딩 지연일 수 있으므로, 늦은 타이머에서만 트리거)
if (delay >= 1000) {
reloadTriggeredRef.current = true;
setReloadTick(t => t + 1);
}
}
}, delay)
);
return () => checks.forEach(clearTimeout);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── 데이터 ────────────────────────────────────────────────────────────
const useUpbit = isUpbitMarket(symbol);
// 과거 데이터 추가 로드 (차트를 왼쪽으로 스크롤할 때)
const { isLoadingMore, loadMoreRef } = useHistoryLoader({
symbol, timeframe, isUpbit: useUpbit, managerRef,
});
const [simBars, setSimBars] = useState<OHLCVBar[]>(() =>
!isUpbitMarket(symbol) ? generateBars(symbol, timeframe) : []
);
useEffect(() => {
if (useUpbit) return;
setSimBars(generateBars(symbol, timeframe));
setLegend(null);
}, [symbol, timeframe, useUpbit]);
const pendingRealtimeBarRef = useRef<OHLCVBar | null>(null);
const handleTickUpdate = useCallback((bar: OHLCVBar) => {
if (!managerRef.current) {
pendingRealtimeBarRef.current = bar;
return;
}
managerRef.current.updateBar(bar);
}, []);
const handleNewCandle = useCallback((bar: OHLCVBar) => {
if (!managerRef.current) {
pendingRealtimeBarRef.current = bar;
return;
}
managerRef.current.appendBar(bar);
}, []);
const { bars: upbitBars, latestBar, wsStatus, isLoading } = useChartRealtimeData(
symbol, timeframe,
useMemo(() => ({
onTickUpdate: handleTickUpdate,
onNewCandle: handleNewCandle,
enabled: useUpbit,
source: chartRealtimeSource,
}), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]),
);
const bars = useUpbit ? upbitBars : simBars;
const lastKnownBar = useUpbit
? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null))
: (bars.length > 0 ? bars[bars.length - 1] : null);
const currentBar = legend ?? lastKnownBar;
const isUp = currentBar ? currentBar.close >= currentBar.open : true;
const prevClose = bars.length > 1 ? bars[bars.length - 2].close : currentBar?.open ?? 0;
const dailyChange = currentBar ? currentBar.close - prevClose : 0;
const dailyChangePct = prevClose > 0 ? dailyChange / prevClose * 100 : 0;
// ── 인디케이터 핸들러 ─────────────────────────────────────────────────
const handleIndicatorSave = useCallback((updated: IndicatorConfig) => {
setIndicators(prev => prev.map(i => i.id === updated.id ? updated : i));
setSettingsModalId(null);
setCtxToolbar(null);
// 파라미터 변경 DB 저장 → 백엔드 Ta4j 계산에 반영
saveParams(updated.type, updated.params);
// 시각 설정(색상·선굵기·수평선) 변경 DB 저장 → 새 지표 추가 시 기본값으로 사용
saveVisual(updated.type, updated.plots, updated.hlines, updated.cloudColors);
}, [saveParams, saveVisual]);
const handleToggleHidden = useCallback((id: string) => {
setIndicators(prev => prev.map(i => i.id === id ? { ...i, hidden: !i.hidden } : i));
}, []);
const handleRemoveById = useCallback((id: string) => {
setIndicators(prev => prev.filter(i => i.id !== id));
setCtxToolbar(null);
setSettingsModalId(null);
}, []);
const handleReorderIndicators = useCallback((fromId: string, insertBeforeId: string | null) => {
setIndicators(prev => reorderIndicatorGroup(fromId, insertBeforeId, prev));
}, []);
const handleMergeIndicators = useCallback((fromId: string, intoId: string) => {
setIndicators(prev => mergeIndicators(fromId, intoId, prev));
}, []);
const handleSplitIndicatorPane = useCallback((hostId: string) => {
setIndicators(prev => splitIndicatorPane(hostId, prev));
}, []);
const [focusedIndicatorId, setFocusedIndicatorId] = useState<string | null>(null);
const handleExpandIndicator = useCallback((id: string) => {
setFocusedIndicatorId(id);
}, []);
const handleRestoreIndicators = useCallback(() => {
setFocusedIndicatorId(null);
}, []);
const handleDuplicateIndicator = useCallback((sourceId: string) => {
setIndicators(prev => duplicateIndicatorInList(sourceId, prev, newIndId) ?? prev);
}, []);
const handleRemoveByIdSlot = useCallback((id: string) => {
setIndicators(prev => prev.filter(i => i.id !== id));
setFocusedIndicatorId(prev => prev === id ? null : prev);
setCtxToolbar(null);
setSettingsModalId(null);
}, []);
/** 단독 전체화면에서 보여줄 지표 목록 (overlay + 선택된 1개) + 타임프레임 필터 */
const effectiveIndicators = useMemo(() => {
// 타임프레임 필터: timeframeVisibility[currentTf] === false 이면 제외
const tfFiltered = indicators.filter(ind =>
ind.timeframeVisibility?.[timeframe] !== false
);
if (!focusedIndicatorId) return tfFiltered;
return tfFiltered.filter(ind => {
const def = getIndicatorDef(ind.type);
return def?.overlay || def?.returnsMarkers || ind.id === focusedIndicatorId;
});
}, [focusedIndicatorId, indicators, timeframe]);
// ── 심볼 선택 ─────────────────────────────────────────────────────────
const handleSymbolSelect = useCallback((s: string) => {
if (isUpbitMarket(s)) invalidateMarketCache(s);
setSymbol(s);
setLegend(null);
setShowMarket(false);
}, []);
return (
<div
className={`chart-slot${active ? ' active' : ''}`}
onClick={onActivate}
>
{/* ── 슬롯 미니 헤더 ────────────────────────────────────────────────── */}
<div className="slot-header" onClick={e => e.stopPropagation()}>
{/* 심볼 버튼: 클릭 시 슬롯 영역 rect를 캡처 후 MarketSearchPanel Portal 오픈 */}
<button
className={`slot-sym-btn${showMarket ? ' open' : ''}`}
title="종목 변경"
onClick={() => {
if (!showMarket) {
// 클릭 시점에 슬롯 전체(.chart-slot) 컨테이너의 rect를 캡처
const slotEl = slotContainerRef.current?.closest('.chart-slot') as HTMLElement | null;
setSlotAnchorRect(slotEl?.getBoundingClientRect() ?? null);
}
setShowMarket(v => !v);
}}
>
<svg width="11" height="11" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6" style={{ flexShrink: 0 }}>
<circle cx="6" cy="6" r="4"/><line x1="9.5" y1="9.5" x2="13" y2="13"/>
</svg>
{getKoreanName(symbol) !== symbol
? <><span className="slot-sym-kr">{getKoreanName(symbol)}</span><span className="slot-sym-code">{symbol}</span></>
: <span className="slot-sym-kr">{symbol}</span>
}
</button>
{/* MarketSearchPanel: body 포털로 렌더 + anchorRect로 해당 슬롯 영역 안에 표시 */}
{showMarket && ReactDOM.createPortal(
<MarketSearchPanel
currentMarket={symbol}
onSelect={s => { handleSymbolSelect(s); }}
onClose={() => setShowMarket(false)}
anchorRect={slotAnchorRect}
/>,
document.body,
)}
{/* 타임프레임 선택 */}
<div className="slot-tf-row">
{TF_OPTIONS.map(tf => (
<button
key={tf}
className={`slot-tf-btn${tf === timeframe ? ' active' : ''}`}
onClick={() => setTimeframe(tf)}
>
{tf}
</button>
))}
</div>
{/* 현재가 */}
{currentBar && (
<span className="slot-price" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
{formatPrice(currentBar.close)}
<span className="slot-change" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
{isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}%
</span>
</span>
)}
{useUpbit && <WsBadge status={wsStatus} isUpbit={true} />}
{isLoading && <span className="slot-loading">...</span>}
</div>
{/* ── TradingChart ─────────────────────────────────────────────────── */}
<div
ref={slotContainerRef}
className="slot-chart-wrap"
style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}
>
{useUpbit && isLoading && (
<div className="chart-loading" style={{ position: 'absolute', zIndex: 10 }}>
<div className="loading-spinner" />
</div>
)}
{isLoadingMore && (
<div className="chart-history-loading">
<div className="loading-spinner" style={{ width: 14, height: 14 }} />
<span> ...</span>
</div>
)}
<TradingChart
key={reloadTick}
bars={bars}
market={symbol}
timeframe={timeframe}
displayTimezone={displayTimezone}
chartType={chartType}
theme={theme}
mode="chart"
indicators={effectiveIndicators}
drawingTool="cursor"
drawings={drawings}
logScale={false}
drawingsLocked={false}
drawingsVisible={true}
onCrosshair={data => {
setLegend(data);
if (data && onCrosshairTime) onCrosshairTime(data.time ?? 0);
}}
onManagerReady={mgr => {
managerRef.current = mgr;
const pending = pendingRealtimeBarRef.current;
if (pending) {
pendingRealtimeBarRef.current = null;
const last = bars.length > 0 ? bars[bars.length - 1] : null;
if (last && pending.time === last.time) mgr.updateBar(pending);
else if (!last || pending.time > last.time) void mgr.appendBar(pending);
else mgr.updateBar({ ...pending, time: last.time });
}
mgr.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels);
mgr.setVolumeVisible(chartVolumeVisible);
// Time sync: 가시 시간 범위 변화 구독
mgr.subscribeVisibleTimeRange(r => {
if (!r || isSyncingTimeRef.current) return;
onVisibleRangeChangeRef.current?.(r);
});
// Date range sync: 가시 논리 범위 변화 구독
// + 왼쪽 스크롤 감지 → 과거 데이터 추가 로드
mgr.subscribeVisibleLogicalRange(r => {
if (!r) return;
if (!isSyncingLogicalRef.current) {
onLogicalRangeChangeRef.current?.(r);
}
// 가시 범위 왼쪽 끝이 LOAD_MORE_TRIGGER 이내이면 추가 로드
if (r.from < LOAD_MORE_TRIGGER) {
loadMoreRef.current();
}
});
}}
onDataLoaded={() => {
// reloadAll(setData + setInitialVisibleRange) 완료 후
// 첫 마운트 시 pending 상태였던 sync range 를 재적용한다
const mgr = managerRef.current;
if (!mgr) return;
if (pendingSyncVisibleRef.current) {
isSyncingTimeRef.current = true;
mgr.applyVisibleTimeRange(
pendingSyncVisibleRef.current.from,
pendingSyncVisibleRef.current.to,
);
requestAnimationFrame(() => { isSyncingTimeRef.current = false; });
pendingSyncVisibleRef.current = null;
}
if (pendingSyncLogicalRef.current) {
isSyncingLogicalRef.current = true;
mgr.applyVisibleLogicalRange(
pendingSyncLogicalRef.current.from,
pendingSyncLogicalRef.current.to,
);
requestAnimationFrame(() => { isSyncingLogicalRef.current = false; });
pendingSyncLogicalRef.current = null;
}
}}
magnifierEnabled={magnifierEnabled}
onMagnifierClose={onMagnifierClose}
onAddDrawing={() => {}}
onSeriesClick={(entryId, point) => {
if (!entryId) { setCtxToolbar(null); return; }
setCtxToolbar({ entryId, screenX: point.x, screenY: point.y });
}}
onSeriesDoubleClick={entryId => {
setCtxToolbar(null);
if (!entryId) return;
if (entryId === '__main__') setShowMainChartModal(true);
else setSettingsModalId(entryId);
}}
onHoverPaneLegend={() => {}}
onLeavePaneLegend={() => {}}
onTradeOrderRequest={req => onTradeOrderRequest?.(req, slotIndex)}
onReorderIndicators={handleReorderIndicators}
onMergeIndicators={handleMergeIndicators}
onSplitIndicatorPane={handleSplitIndicatorPane}
onExpandIndicator={handleExpandIndicator}
onRemoveIndicator={handleRemoveByIdSlot}
onDuplicateIndicator={handleDuplicateIndicator}
focusedIndicatorId={focusedIndicatorId}
onRestoreIndicators={handleRestoreIndicators}
onFullView={onFullView ? () => onFullView(symbolRef.current) : undefined}
/>
{/* 컨텍스트 툴바 */}
{ctxToolbar && (() => {
const cfg = ctxToolbar.entryId !== '__main__'
? indicators.find(i => i.id === ctxToolbar.entryId) ?? null
: null;
return (
<IndicatorContextToolbar
indicatorId={ctxToolbar.entryId}
config={cfg}
chartType={chartType}
screenX={ctxToolbar.screenX}
screenY={ctxToolbar.screenY}
onMouseEnterToolbar={() => {}}
onMouseLeaveToolbar={() => {}}
onSettings={() => {
const id = ctxToolbar.entryId;
setCtxToolbar(null);
if (id === '__main__') setShowMainChartModal(true);
else setSettingsModalId(id);
}}
onToggleHidden={() => handleToggleHidden(ctxToolbar.entryId)}
onRemove={() => handleRemoveById(ctxToolbar.entryId)}
onClose={() => setCtxToolbar(null)}
/>
);
})()}
{/* 메인 차트 설정 모달 */}
{showMainChartModal && (
<MainChartSettingsModal
style={mainChartStyle}
onSave={style => { setMainChartStyle(style); setShowMainChartModal(false); }}
onCancel={() => setShowMainChartModal(false)}
/>
)}
{/* 지표 설정 모달 */}
{settingsModalId && settingsModalId !== '__main__' && (() => {
const cfg = indicators.find(i => i.id === settingsModalId);
if (!cfg) return null;
const def = getIndicatorDef(cfg.type);
const mergedCfg = enrichIndicatorConfig({
...cfg,
params: getParams(cfg.type, def?.defaultParams),
hlines: cfg.hlines ?? def?.hlines,
});
return (
<IndicatorSettingsModal
config={mergedCfg}
chartMarket={symbol}
onSave={handleIndicatorSave}
onCancel={() => setSettingsModalId(null)}
/>
);
})()}
</div>
</div>
);
});
export default ChartSlot;