종목변경 시 캔들차트 사라지는 현상 수정

This commit is contained in:
Macbook
2026-05-24 21:51:27 +09:00
parent af230a4233
commit 3c66c562d8
14 changed files with 288 additions and 219 deletions
+10 -4
View File
@@ -654,6 +654,7 @@ function App() {
/** applyBacktestMarkersWhenReady rAF 재시도 시 최신 차트 종목·타임프레임 검증용 */
const chartContextRef = useRef({ symbol, timeframe });
chartContextRef.current = { symbol, timeframe };
const barsMarketRef = useRef<string | null>(null);
const syncBacktestMarkersRef = useRef<() => void>(() => {});
@@ -730,8 +731,9 @@ function App() {
const liveStompSubscriptions = useMemo(() => {
if (!appDefaults.liveStrategyCheck || !appDefaults.liveStrategyId) return [];
if (marketSubscriptions.length > 0) return marketSubscriptions;
return monitoredMarkets.map(m => ({ market: m, candleType: '1m' as const }));
}, [appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, marketSubscriptions, monitoredMarkets]);
const ct = liveCandleType || '1m';
return monitoredMarkets.map(m => ({ market: m, candleType: ct }));
}, [appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, marketSubscriptions, monitoredMarkets, liveCandleType]);
useEffect(() => {
if (!appDefaults.liveStrategyCheck) {
@@ -942,6 +944,7 @@ function App() {
// ── Upbit 실시간 데이터 ─────────────────────────────────────────────────
// 틱 수신: 동일 캔들 업데이트 → React state 변경 없이 차트만 직접 갱신
const handleTickUpdate = useCallback((bar: OHLCVBar) => {
if (barsMarketRef.current !== chartContextRef.current.symbol) return;
if (!managerRef.current) {
pendingRealtimeBarRef.current = bar;
flashWsDot();
@@ -953,6 +956,7 @@ function App() {
// 새 캔들: bars state를 변경하지 않고 차트에 바만 추가 + 인디케이터 last point 갱신
const handleNewCandle = useCallback((newBar: OHLCVBar) => {
if (barsMarketRef.current !== chartContextRef.current.symbol) return;
if (!managerRef.current) {
pendingRealtimeBarRef.current = newBar;
flashWsDot();
@@ -963,7 +967,7 @@ function App() {
}, []);
const chartRealtimeSource = appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP';
const { bars: upbitBars, latestBar, wsStatus, isLoading, error } = useChartRealtimeData(
const { bars: upbitBars, barsMarket: upbitBarsMarket, latestBar, wsStatus, isLoading, error } = useChartRealtimeData(
symbol,
timeframe,
useMemo(() => ({
@@ -976,6 +980,8 @@ function App() {
// 실제로 차트에 넘길 bars (초기 200개, 실시간 수신 후에도 변경 없음)
const bars = useUpbit ? upbitBars : simBars;
const barsMarket = useUpbit ? upbitBarsMarket : symbol;
barsMarketRef.current = barsMarket;
// Upbit 초기 로딩 완료 시 stats 업데이트
useEffect(() => {
@@ -1955,7 +1961,7 @@ function App() {
<TradingChart
key={chartMountKey}
bars={bars} market={symbol} timeframe={timeframe} displayTimezone={displayTimezone}
bars={bars} barsMarket={barsMarket} market={symbol} timeframe={timeframe} displayTimezone={displayTimezone}
chartType={chartType} theme={theme} mode={mode}
indicators={effectiveIndicators} drawingTool={drawingTool}
drawings={drawings} logScale={logScale}
+7 -1
View File
@@ -423,8 +423,10 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
}, [symbol, timeframe, useUpbit]);
const pendingRealtimeBarRef = useRef<OHLCVBar | null>(null);
const barsMarketRef = useRef<string | null>(null);
const handleTickUpdate = useCallback((bar: OHLCVBar) => {
if (barsMarketRef.current !== symbolRef.current) return;
if (!managerRef.current) {
pendingRealtimeBarRef.current = bar;
return;
@@ -432,6 +434,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
managerRef.current.updateBar(bar);
}, []);
const handleNewCandle = useCallback((bar: OHLCVBar) => {
if (barsMarketRef.current !== symbolRef.current) return;
if (!managerRef.current) {
pendingRealtimeBarRef.current = bar;
return;
@@ -439,7 +442,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
managerRef.current.appendBar(bar);
}, []);
const { bars: upbitBars, latestBar, wsStatus, isLoading } = useChartRealtimeData(
const { bars: upbitBars, barsMarket: upbitBarsMarket, latestBar, wsStatus, isLoading } = useChartRealtimeData(
symbol, timeframe,
useMemo(() => ({
onTickUpdate: handleTickUpdate,
@@ -450,6 +453,8 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
);
const bars = useUpbit ? upbitBars : simBars;
const barsMarket = useUpbit ? upbitBarsMarket : symbol;
barsMarketRef.current = barsMarket;
const lastKnownBar = useUpbit
? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null))
@@ -623,6 +628,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
<TradingChart
key={reloadTick}
bars={bars}
barsMarket={barsMarket}
market={symbol}
timeframe={timeframe}
displayTimezone={displayTimezone}
+2 -7
View File
@@ -3,7 +3,6 @@
*/
import React, { useState } from 'react';
import { loginUser, type LoginResponse } from '../utils/backendApi';
import SplashChartBackground from './splash/SplashChartBackground';
import '../styles/splashScreen.css';
const DEFAULT_USERNAME = 'admin';
@@ -37,12 +36,8 @@ const SplashScreen: React.FC<Props> = ({ onLoginSuccess, onGuest }) => {
return (
<div className="splash">
<div className="splash-bg" aria-hidden />
<div className="splash-chart-layer" aria-hidden>
<SplashChartBackground />
</div>
<div className="splash-chart-fade" aria-hidden />
<div className="splash-photo-bg" aria-hidden />
<div className="splash-photo-overlay" aria-hidden />
<div className="splash-center">
<div className="splash-glass">
+90 -16
View File
@@ -15,6 +15,8 @@ import { DEFAULT_DISPLAY_TIMEZONE } from '../utils/timezone';
interface TradingChartProps {
bars: OHLCVBar[];
/** bars 가 현재 market 과 일치할 때만 전달 (없으면 bars.length>0 이면 준비된 것으로 간주) */
barsMarket?: string | null;
/** BB 등 다른 심볼 계산용 */
market?: string;
timeframe?: Timeframe;
@@ -78,7 +80,7 @@ interface TradingChartProps {
}
const TradingChart: React.FC<TradingChartProps> = ({
bars, market = '', timeframe = '1D', chartType, theme, mode, indicators, drawingTool, drawings,
bars, barsMarket, market = '', timeframe = '1D', chartType, theme, mode, indicators, drawingTool, drawings,
logScale, drawingsLocked = false, drawingsVisible = true,
onCrosshair, onManagerReady, onAddDrawing,
onSeriesClick, onSeriesDoubleClick,
@@ -255,6 +257,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
// ── 전체 재로드 (데이터 + 인디케이터) ─────────────────────────────────────
const reloadSafetyTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
const reloadGenRef = useRef(0);
const reloadRetryRef = useRef(0);
const reloadAll = useCallback(async (
mgr: ChartManager,
@@ -266,10 +270,50 @@ const TradingChart: React.FC<TradingChartProps> = ({
) => {
if (newBars.length === 0) return;
const gen = ++reloadGenRef.current;
reloadSafetyTimers.current.forEach(clearTimeout);
reloadSafetyTimers.current = [];
barsRef.current = newBars;
barsRef.current = newBars;
mgr.setData(newBars, ct, th);
if (gen !== reloadGenRef.current) {
prevBarsKey.current = '';
return;
}
mgr.setTheme(th);
mgr.setLogScale(ls);
mgr.setDisplayTimezone(displayTimezone, timeframe);
for (const ind of inds) {
if (gen !== reloadGenRef.current) {
prevBarsKey.current = '';
return;
}
await mgr.addIndicator(ind);
}
if (gen !== reloadGenRef.current) {
prevBarsKey.current = '';
return;
}
const expectIndicators = inds.some(i => i.hidden !== true);
if (expectIndicators && mgr.getIndicatorCount() === 0) {
prevBarsKey.current = '';
if (reloadRetryRef.current < 3) {
reloadRetryRef.current += 1;
requestAnimationFrame(() => {
if (managerRef.current) {
void reloadAll(managerRef.current, newBars, ct, th, ls, inds);
}
});
}
return;
}
reloadRetryRef.current = 0;
prevChartType.current = ct;
prevTheme.current = th;
prevLogScale.current = ls;
@@ -277,15 +321,6 @@ const TradingChart: React.FC<TradingChartProps> = ({
prevIndKey.current = indKey(inds);
prevSortedPKRef.current = sortedParamKey(inds);
mgr.setData(newBars, ct, th);
mgr.setTheme(th);
mgr.setLogScale(ls);
mgr.setDisplayTimezone(displayTimezone, timeframe);
for (const ind of inds) {
await mgr.addIndicator(ind);
}
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => {
applyPaneLayout(mgr);
requestAnimationFrame(() => {
@@ -295,6 +330,11 @@ const TradingChart: React.FC<TradingChartProps> = ({
});
})));
if (gen !== reloadGenRef.current) {
prevBarsKey.current = '';
return;
}
onDataLoaded?.();
reloadSafetyTimers.current = [300, 700, 1400].map(delay =>
@@ -611,6 +651,12 @@ const TradingChart: React.FC<TradingChartProps> = ({
prevMarket.current = market;
prevMarketTf.current = timeframe;
prevBarsKey.current = '';
prevIndKey.current = '';
reloadRetryRef.current = 0;
reloadGenRef.current += 1;
reloadSafetyTimers.current.forEach(clearTimeout);
reloadSafetyTimers.current = [];
managerRef.current?.cancelPendingIndicatorUpdates();
}, [market, timeframe]);
// ── 데이터/인디케이터 동기화 ─────────────────────────────────────────────
@@ -618,6 +664,11 @@ const TradingChart: React.FC<TradingChartProps> = ({
const mgr = managerRef.current;
if (!mgr || !chartMgr || bars.length === 0) return;
const barsReady = barsMarket === undefined
? true
: barsMarket === market;
if (!barsReady) return;
// manager 준비 전 bars 가 먼저 도착한 경우(로그인 직후 등) 메인 시리즈 누락 방지
if (!mgr.hasMainSeries()) {
void reloadAll(mgr, bars, chartType, theme, logScale, indicators);
@@ -631,12 +682,16 @@ const TradingChart: React.FC<TradingChartProps> = ({
const ctChanged = chartType !== prevChartType.current;
const thChanged = theme !== prevTheme.current;
const lsChanged = logScale !== prevLogScale.current;
const expectIndicators = indicators.some(i => i.hidden !== true);
const indicatorsIncomplete = expectIndicators
&& mgr.hasMainSeries()
&& mgr.getIndicatorCount() === 0;
if (!barsChanged && !indChanged && !ctChanged && !thChanged && !lsChanged) return;
if (!barsChanged && !indChanged && !ctChanged && !thChanged && !lsChanged && !indicatorsIncomplete) return;
if (barsChanged || ctChanged || thChanged) {
// 데이터 또는 차트 형식 변경 → 전체 재로드
reloadAll(mgr, bars, chartType, theme, logScale, indicators);
if (barsChanged || ctChanged || thChanged || indicatorsIncomplete) {
// 데이터·차트 형식 변경 또는 지표 누락 → 전체 재로드
void reloadAll(mgr, bars, chartType, theme, logScale, indicators);
} else if (lsChanged) {
prevLogScale.current = logScale;
mgr.setLogScale(logScale);
@@ -715,7 +770,26 @@ const TradingChart: React.FC<TradingChartProps> = ({
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chartMgr, bars, chartType, theme, logScale, indicators]);
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators]);
// 종목 전환 직후 reloadAll 이 중단되어 캔들만 남은 경우 자동 복구
useEffect(() => {
const mgr = managerRef.current;
if (!mgr || !chartMgr || bars.length === 0) return;
if (barsMarket !== undefined && barsMarket !== market) return;
const expectIndicators = indicators.some(i => i.hidden !== true);
if (!expectIndicators) return;
const t = window.setTimeout(() => {
const m = managerRef.current;
if (!m || !m.hasMainSeries() || m.getIndicatorCount() > 0) return;
prevBarsKey.current = '';
void reloadAll(m, bars, chartType, theme, logScale, indicators);
}, 120);
return () => window.clearTimeout(t);
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, reloadAll]);
// cursor 모드에서 드로잉 위에 있으면 pointer 커서로 변경
const handleWrapperMouseMove = useCallback((e: React.PointerEvent) => {
+19 -5
View File
@@ -53,6 +53,8 @@ interface Options {
interface Return {
bars: OHLCVBar[];
/** bars 가 어느 종목 기준인지 (로딩 중·전환 직후 null) */
barsMarket: string | null;
latestBar: OHLCVBar | null;
wsStatus: WsStatus;
isLoading: boolean;
@@ -105,6 +107,7 @@ export function useStompChartData(
): Return {
const candleType = timeframeToCandleType(timeframe);
const [bars, setBars] = useState<OHLCVBar[]>([]);
const [barsMarket, setBarsMarket] = useState<string | null>(null);
const [latestBar, setLatestBar] = useState<OHLCVBar | null>(null);
const [wsStatus, setWsStatus] = useState<WsStatus>('connecting');
const [isLoading, setIsLoading] = useState(true);
@@ -117,13 +120,17 @@ export function useStompChartData(
/** 1m 봉 volume 스냅샷 — 상위 분봉 volume 델타 계산용 */
const last1mVolRef = useRef<number>(0);
const last1mTimeRef = useRef<number>(0);
/** 초기 히스토리 로드 완료 전에는 차트 직접 갱신 콜백을 호출하지 않음 */
const historyReadyRef = useRef(false);
useEffect(() => {
if (opts.enabled === false) return;
let cancelled = false;
historyReadyRef.current = false;
setIsLoading(true);
setError(null);
setBars([]);
setBarsMarket(null);
barsRef.current = [];
last1mVolRef.current = 0;
last1mTimeRef.current = 0;
@@ -134,6 +141,8 @@ export function useStompChartData(
.then(newBars => {
if (cancelled) return;
barsRef.current = newBars;
historyReadyRef.current = newBars.length > 0;
setBarsMarket(market);
setBars(newBars);
setLatestBar(newBars[newBars.length - 1] ?? null);
lastTimeRef.current = newBars[newBars.length - 1]?.time ?? 0;
@@ -159,10 +168,11 @@ export function useStompChartData(
const pushBar = (bar: OHLCVBar) => {
const current = barsRef.current;
const notifyChart = historyReadyRef.current;
if (current.length === 0) {
barsRef.current = [bar];
setLatestBar(bar);
optsRef.current.onNewCandle(bar);
if (notifyChart) optsRef.current.onNewCandle(bar);
lastTimeRef.current = bar.time;
return;
}
@@ -170,16 +180,16 @@ export function useStompChartData(
if (bar.time === last.time) {
barsRef.current[current.length - 1] = bar;
setLatestBar(bar);
optsRef.current.onTickUpdate(bar);
if (notifyChart) optsRef.current.onTickUpdate(bar);
} else if (bar.time > last.time) {
barsRef.current = [...current, bar];
setLatestBar(bar);
optsRef.current.onNewCandle(bar);
if (notifyChart) optsRef.current.onNewCandle(bar);
} else {
const patched = { ...bar, time: last.time };
barsRef.current[current.length - 1] = patched;
setLatestBar(patched);
optsRef.current.onTickUpdate(patched);
if (notifyChart) optsRef.current.onTickUpdate(patched);
}
lastTimeRef.current = bar.time;
};
@@ -255,12 +265,15 @@ export function useStompChartData(
setWsStatus('connecting');
setError(null);
historyReadyRef.current = false;
last1mVolRef.current = 0;
last1mTimeRef.current = 0;
void pinChartWatch(market, STOMP_TICK_CANDLE_TYPE);
const topic = `/sub/charts/${market}/${STOMP_TICK_CANDLE_TYPE}`;
let alive = true;
const unsub = subscribeStompTopic(topic, msg => {
if (!alive) return;
setWsStatus('connected');
const data = parseStompJson<StompCandlePayload>(msg);
if (data?.time == null || typeof data.time !== 'number') return;
@@ -270,11 +283,12 @@ export function useStompChartData(
const t = window.setTimeout(() => setWsStatus('connected'), 800);
return () => {
alive = false;
window.clearTimeout(t);
unsub();
setWsStatus('disconnected');
};
}, [market, timeframe, opts.enabled, applyCandle]);
return { bars, latestBar, wsStatus, isLoading, error };
return { bars, barsMarket, latestBar, wsStatus, isLoading, error };
}
+6 -1
View File
@@ -31,6 +31,8 @@ interface UpbitDataOptions {
interface UseUpbitDataReturn {
/** 초기 200개 캔들 (심볼/타임프레임 변경 시에만 갱신) */
bars: OHLCVBar[];
/** bars 가 어느 종목 기준인지 (로딩 중·전환 직후 null) */
barsMarket: string | null;
/** 현재 진행 중인 캔들의 최신 스냅샷 (UI 표시용) */
latestBar: OHLCVBar | null;
wsStatus: WsStatus;
@@ -56,6 +58,7 @@ export function useUpbitData(
): UseUpbitDataReturn {
// bars = 초기 FETCH_COUNT개 (REST), 실시간 수신 시 변경 안 함 → TradingChart setData 재호출 방지
const [bars, setBars] = useState<OHLCVBar[]>([]);
const [barsMarket, setBarsMarket] = useState<string | null>(null);
const [latestBar, setLatestBar] = useState<OHLCVBar | null>(null);
const [wsStatus, setWsStatus] = useState<WsStatus>('connecting');
const [isLoading, setIsLoading] = useState(true);
@@ -76,6 +79,7 @@ export function useUpbitData(
setIsLoading(true);
setError(null);
setBars([]);
setBarsMarket(null);
setLatestBar(null);
barsRef.current = [];
@@ -92,6 +96,7 @@ export function useUpbitData(
.then(newBars => {
if (cancelled) return;
barsRef.current = newBars;
setBarsMarket(market);
setBars(newBars);
setLatestBar(newBars[newBars.length - 1] ?? null);
setIsLoading(false);
@@ -169,5 +174,5 @@ export function useUpbitData(
return () => client.destroy();
}, [market, timeframe, handleMessage, opts.enabled]);
return { bars, latestBar, wsStatus, isLoading, error };
return { bars, barsMarket, latestBar, wsStatus, isLoading, error };
}
+12 -80
View File
@@ -1,4 +1,4 @@
/* GoldenChart splash — glassmorphism login + 은은한 차트 배경 */
/* GoldenChart splash — glassmorphism login + 차트 콜라주 배경 이미지 */
.splash {
position: fixed;
@@ -7,104 +7,36 @@
display: flex;
align-items: center;
justify-content: center;
background: #0a0c10;
background: #06080d;
color: #e8eaed;
overflow: hidden;
font-family: var(--font, 'Noto Sans KR', 'Inter', sans-serif);
}
.splash-bg {
.splash-photo-bg {
position: absolute;
inset: 0;
z-index: 0;
background:
radial-gradient(ellipse 70% 50% at 78% 35%, rgba(45, 212, 191, 0.07), transparent 58%),
linear-gradient(165deg, #0c0e13 0%, #0a0c10 55%, #08090d 100%);
pointer-events: none;
}
/* 전체 화면 차트 — 우상향 캔들 + 일목 구름 */
.splash-chart-layer {
position: absolute;
inset: -4% -6% -4% -2%;
z-index: 0;
overflow: hidden;
pointer-events: none;
transform: translate(3%, -4%) scale(1.08);
transform-origin: 75% 35%;
}
.splash-chart-bg {
display: block;
width: 100%;
height: 100%;
opacity: 0.62;
.splash-photo-bg::before {
content: '';
position: absolute;
inset: 0;
background: url('/splash-bg.png?v=3') center center / cover no-repeat;
filter: saturate(1.06) brightness(0.94);
}
.splash-chart-fade {
.splash-photo-overlay {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background:
radial-gradient(ellipse 42% 38% at 50% 52%, rgba(10, 12, 16, 0.55) 0%, rgba(10, 12, 16, 0.82) 72%),
linear-gradient(125deg, rgba(10, 12, 16, 0.72) 0%, rgba(10, 12, 16, 0.12) 38%, rgba(10, 12, 16, 0.45) 100%),
linear-gradient(to top, rgba(10, 12, 16, 0.35) 0%, transparent 28%);
}
.splash-chart-grid {
stroke: rgba(255, 255, 255, 0.05);
stroke-width: 1;
}
.splash-chart-kumo {
opacity: 0.85;
}
.splash-ichi {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.splash-ichi--tenkan {
stroke: rgba(56, 189, 248, 0.35);
stroke-width: 1.4;
}
.splash-ichi--kijun {
stroke: rgba(244, 114, 182, 0.32);
stroke-width: 1.4;
}
.splash-ichi--senkou-a {
stroke: rgba(34, 197, 94, 0.28);
stroke-width: 1;
stroke-dasharray: 4 4;
}
.splash-ichi--senkou-b {
stroke: rgba(239, 68, 68, 0.25);
stroke-width: 1;
stroke-dasharray: 4 4;
}
.splash-candle-wick {
stroke: rgba(203, 213, 225, 0.45);
stroke-width: 1.3;
}
.splash-candle-up {
fill: rgba(45, 212, 191, 0.55);
stroke: rgba(94, 234, 212, 0.75);
stroke-width: 1;
}
.splash-candle-down {
fill: rgba(100, 116, 139, 0.5);
stroke: rgba(148, 163, 184, 0.45);
stroke-width: 1;
}
.splash-chart-volume {
opacity: 0.35;
}
.splash-vol-bar {
fill: rgba(100, 116, 139, 0.35);
radial-gradient(ellipse 108% 92% at 50% 50%, transparent 36%, rgba(2, 4, 8, 0.48) 100%),
linear-gradient(to bottom, rgba(2, 4, 8, 0.28) 0%, rgba(2, 4, 8, 0.06) 44%, rgba(2, 4, 8, 0.3) 100%);
}
.splash-center {
+38 -20
View File
@@ -164,6 +164,18 @@ export class ChartManager {
private drawingLines: IPriceLine[] = [];
private _clickHandler: ((p: MouseEventParams<Time>) => void) | null = null;
private rawBars: OHLCVBar[] = [];
/** setData 호출 세대 — addIndicator 중 stale rawBars 적용 방지 */
private _dataGeneration = 0;
/** 진행 중인 보조지표 스로틀 갱신 취소 (종목 전환·재로드 시) */
cancelPendingIndicatorUpdates(): void {
if (this._indUpdateTimer) {
clearTimeout(this._indUpdateTimer);
this._indUpdateTimer = null;
}
this._pendingIndUpdate = false;
this._indRunning = false;
}
private currentTheme: Theme = 'dark';
private currentChartType: ChartType = 'candlestick';
private displayTimezone = DEFAULT_DISPLAY_TIMEZONE;
@@ -258,12 +270,8 @@ export class ChartManager {
setData(bars: OHLCVBar[], chartType: ChartType, theme: Theme): void {
// 전체 재로드 전: 진행 중인 지표 갱신 타이머·플래그를 초기화해
// 이전 rawBars 기반의 stale 계산이 새 지표에 적용되는 것을 방지한다.
if (this._indUpdateTimer) {
clearTimeout(this._indUpdateTimer);
this._indUpdateTimer = null;
}
this._pendingIndUpdate = false;
this._indRunning = false;
this.cancelPendingIndicatorUpdates();
this._dataGeneration += 1;
this.rawBars = bars;
this.currentTheme = theme;
@@ -407,6 +415,7 @@ export class ChartManager {
async addIndicator(config: IndicatorConfig): Promise<void> {
config = enrichIndicatorConfig(config);
if (this.indicators.has(config.id) || this.rawBars.length === 0) return;
const dataGenAtStart = this._dataGeneration;
const indicatorHidden = config.hidden === true;
const def = getIndicatorDef(config.type);
const seriesList: ISeriesApi<SeriesType>[] = [];
@@ -416,6 +425,8 @@ export class ChartManager {
// 스냅샷 전달: await 중 rawBars 가 변경돼도 이 지표는 일관된 초기 데이터로 setData
const result = await calculateIndicator(config.type, this.rawBars.slice(), config.params);
if (dataGenAtStart !== this._dataGeneration) return;
if (def?.returnsMarkers && result.markers && result.markers.length > 0) {
this.patternMarkers.push({ id: config.id, markers: result.markers });
this._reapplyAllPatternMarkers();
@@ -427,10 +438,7 @@ export class ChartManager {
// ─── 일목균형표 특별 처리 ────────────────────────────────────────────
if (config.type === 'IchimokuCloud') {
const cloudPlugin = await this._addIchimokuSeries(result, pane, config, seriesList);
// 일목균형표 5라인 plotId: plot0~plot4 순서 고정
const ichiPlotIds = ['plot0', 'plot1', 'plot2', 'plot3', 'plot4'];
const ichiMeta = seriesList.map((_, idx) => ({ plotId: ichiPlotIds[idx] ?? `plot${idx}`, isHistogram: false, color: '' }));
const { cloudPlugin, seriesMeta: ichiMeta } = await this._addIchimokuSeries(result, pane, config, seriesList);
this.indicators.set(config.id, { id: config.id, type: config.type, seriesList, seriesMeta: ichiMeta, paneIndex: pane, alertLines: [], hlineRefs: [], config, cloudPlugin });
this.applyIndicatorStyle(config);
return;
@@ -717,9 +725,10 @@ export class ChartManager {
pane: number,
_config: IndicatorConfig,
seriesList: ISeriesApi<SeriesType>[],
): Promise<IchimokuCloudPlugin> {
): Promise<{ cloudPlugin: IchimokuCloudPlugin; seriesMeta: IndicatorEntry['seriesMeta'] }> {
const displacement = (_config.params?.displacement as number) ?? 26;
const laggingPeriod = (_config.params?.laggingSpan2Periods as number) ?? 52;
const seriesMeta: IndicatorEntry['seriesMeta'] = [];
const plotInfo = [
{ id: 'plot0', title: getIchimokuPlotTitle('plot0'), color: '#26c6da', lastVal: true },
@@ -763,6 +772,7 @@ export class ChartManager {
}, pane);
series.setData(seriesData);
seriesList.push(series);
seriesMeta.push({ plotId: info.id, isHistogram: false, color: info.color });
if (info.id === 'plot3') spanASeriesRef = series;
}
@@ -775,7 +785,7 @@ export class ChartManager {
host.attachPrimitive(cloudPlugin);
this._applyIchimokuCloudPlugin(cloudPlugin, result.plots, _config);
}
return cloudPlugin;
return { cloudPlugin, seriesMeta };
}
/** 선행스팬A의 미래 26봉: (전환선 + 기준선) / 2, 미래 타임스탬프로 매핑 */
@@ -989,20 +999,24 @@ export class ChartManager {
/** 모든 보조지표 시리즈의 마지막 데이터 포인트를 현재 rawBars 기준으로 재계산·갱신 */
private async _updateIndicatorLastPoints(): Promise<void> {
if (this.rawBars.length === 0) return;
const dataGenAtStart = this._dataGeneration;
// 루프 시작 전 스냅샷 고정: 루프 실행 중 rawBars 가 변경돼도 모든 지표가
// 동일한 데이터 기준으로 계산되어 지표 간 시각적 불일치를 방지한다.
const barsSnapshot = this.rawBars.slice();
for (const [, entry] of this.indicators.entries()) {
if (dataGenAtStart !== this._dataGeneration) return;
if (!entry.config || entry.seriesList.length === 0) continue;
try {
const { plots } = await calculateIndicator(
entry.config.type, barsSnapshot, entry.config.params ?? {}
);
// 일목균형표: 구름 플러그인 + 미래 프로젝션 라인 모두 갱신
if (entry.type === 'IchimokuCloud' && entry.cloudPlugin) {
this._applyIchimokuCloudPlugin(entry.cloudPlugin, plots, entry.config);
if (dataGenAtStart !== this._dataGeneration) return;
// 일목균형표: 선행스팬 미래 구간 포함 전체 setData (틱 갱신 시에도 유지)
if (entry.type === 'IchimokuCloud') {
this._refreshIchimokuSeriesData(entry, plots);
continue;
}
this._applyLastPointsToSeries(entry, plots, /* updateFutureSpans */ false);
} catch { /* 계산 실패 무시 */ }
@@ -1134,15 +1148,12 @@ export class ChartManager {
*/
async appendBar(bar: OHLCVBar): Promise<void> {
if (!this.mainSeries) return;
const dataGenAtStart = this._dataGeneration;
const t = getTheme(this.currentTheme);
// 새 캔들 추가 시 이 함수 내부에서 모든 지표를 직접 재계산하므로,
// updateBar 가 예약한 스로틀 타이머를 취소해 중복 계산을 방지합니다.
if (this._indUpdateTimer) {
clearTimeout(this._indUpdateTimer);
this._indUpdateTimer = null;
this._pendingIndUpdate = false;
}
this.cancelPendingIndicatorUpdates();
// rawBars에 신규 바 추가 (중복 방지)
const last = this.rawBars[this.rawBars.length - 1];
@@ -1181,11 +1192,13 @@ export class ChartManager {
// rawBars 스냅샷을 찍어 루프 도중 rawBars 가 변경돼도 일관된 계산 보장
const barsSnapshot = this.rawBars.slice();
for (const [, entry] of this.indicators.entries()) {
if (dataGenAtStart !== this._dataGeneration) return;
if (!entry.config || entry.seriesList.length === 0) continue;
try {
const { plots } = await calculateIndicator(
entry.config.type, barsSnapshot, entry.config.params ?? {}
);
if (dataGenAtStart !== this._dataGeneration) return;
// 일목균형표: 구름 플러그인 + SpanA/B 미래 라인 갱신
if (entry.type === 'IchimokuCloud' && entry.cloudPlugin) {
this._applyIchimokuCloudPlugin(entry.cloudPlugin, plots, entry.config);
@@ -2356,6 +2369,11 @@ export class ChartManager {
return this.mainSeries !== null && this.rawBars.length > 0;
}
/** 로드된 보조지표 수 (종목 전환 후 캔들만 그려진 상태 감지용) */
getIndicatorCount(): number {
return this.indicators.size;
}
/** 현재 로드된 rawBars 개수 반환 (데이터 로드 여부 확인용) */
getRawBarsLength(): number {
return this.rawBars.length;