종목변경 시 캔들차트 사라지는 현상 수정
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 260 KiB |
+10
-4
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user